From 6a43ee8699463dd9f137c42dec733b0915b23cc5 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 23:51:10 +0200 Subject: [PATCH 1/3] Document heading ID CSS-validity deviation from djot spec Cross-reference jgm/djot#391, where the spec wording on auto-ID generation is being clarified. djot-php aligns with the proposed direction on remove-vs-replace and Unicode preservation, but deliberately deviates on apostrophes, double quotes, semicolons, and colons (replacing rather than preserving) because heading IDs are consumed by querySelector() and need to be valid CSS identifiers. Add a Spec Alignment section to the existing CSS-Safe Heading IDs reference, and pin the relevant edge cases with a focused regression test so the deliberate behavior is locked in if/when the spec lands. --- docs/reference/enhancements.md | 18 ++++++++++++++++- .../Renderer/HeadingIdTrackerTest.php | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/reference/enhancements.md b/docs/reference/enhancements.md index 8825350..984640d 100644 --- a/docs/reference/enhancements.md +++ b/docs/reference/enhancements.md @@ -144,7 +144,7 @@ The ID is `Introduction`, not `Introduction1` or `Introduction[^1]`. ## CSS-Safe Heading IDs -**Related:** [php-collective/djot-php#92](https://github.com/php-collective/djot-php/pull/92) +**Related:** [php-collective/djot-php#92](https://github.com/php-collective/djot-php/pull/92), [jgm/djot#391](https://github.com/jgm/djot/issues/391) **Status:** Implemented in djot-php @@ -217,6 +217,22 @@ You can always override with an explicit ID attribute: Explicit IDs are used as-is without normalization. +### Spec Alignment + +The djot spec's wording on auto-ID generation is being clarified in [jgm/djot#391](https://github.com/jgm/djot/issues/391). djot-php's normalization aligns with the proposed direction in most respects and deliberately deviates in two places — both motivated by producing valid CSS identifiers for `querySelector()` consumers. + +| Aspect | djot.js / djoths (proposed spec) | djot-php | +|--------|---------------------------------|----------| +| Mid-word punctuation (`A+B=C`) | replace with `-` → `A-B-C` | replace with `-` → `A-B-C` | +| Non-ASCII letters (`Über uns`) | preserve → `Über-uns` | preserve → `Über-uns` | +| Consecutive punctuation (`foo...bar`) | collapse to single `-` → `foo-bar` | collapse to single `-` → `foo-bar` | +| Apostrophe (`That's all`) | preserve → `That's-all` | replace with `-` → `That-s-all` | +| Double quote / `;` / `:` | preserve | replace with `-` | +| Leading digit (`2024 recap`) | unspecified | prefix with `h-` → `h-2024-recap` | +| Empty result (`!!!`) | unspecified | fallback → `heading` | + +The apostrophe / quote / semicolon / colon deviation is deliberate: these characters are not valid in unescaped CSS identifiers, so preserving them per the spec would force every JS consumer to round-trip through `CSS.escape()` before doing a selector lookup. The leading-digit and empty-result behaviors fill in spec gaps that other implementations handle inconsistently. + --- ## Symbol Parsing in Time Formats diff --git a/tests/TestCase/Renderer/HeadingIdTrackerTest.php b/tests/TestCase/Renderer/HeadingIdTrackerTest.php index c028f26..6476323 100644 --- a/tests/TestCase/Renderer/HeadingIdTrackerTest.php +++ b/tests/TestCase/Renderer/HeadingIdTrackerTest.php @@ -163,6 +163,26 @@ public function testNormalizeId(): void $this->assertSame('h-1-Introduction', $this->tracker->normalizeId('1. Introduction')); } + /** + * Pins behaviour discussed in jgm/djot#391 (spec wording on auto-ID generation). + * + * djot-php sides with djot.js / djoths on remove-vs-replace (mid-word punctuation + * becomes `-`), and deliberately deviates on apostrophes / quotes / `;` / `:` by + * also replacing them, so generated IDs are valid CSS identifiers and safe to use + * with `querySelector()`. + */ + public function testNormalizeIdSpecAlignmentEdgeCases(): void + { + $this->assertSame('A-B-C', $this->tracker->normalizeId('A+B=C')); + $this->assertSame('Emphasis-strong', $this->tracker->normalizeId('Emphasis/strong')); + $this->assertSame('That-s-all', $this->tracker->normalizeId("That's all")); + $this->assertSame('foo-bar', $this->tracker->normalizeId('foo...bar')); + $this->assertSame('Uber-uns', $this->tracker->normalizeId('Uber uns')); + $this->assertSame('Über-uns', $this->tracker->normalizeId('Über uns')); + $this->assertSame('h-2024-recap', $this->tracker->normalizeId('2024 recap')); + $this->assertSame('heading', $this->tracker->normalizeId('!!!')); + } + public function testGetPlainText(): void { $heading = new Heading(2); From 5bf3d736603dbda6d80cba5903e5a9a8e2d9d534 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 14 May 2026 00:11:43 +0200 Subject: [PATCH 2/3] Exclude conflicting PSR12 anon class rule in phpcs config PhpCollective enables both PSR12.Classes.AnonClassDeclaration.SpaceAfterKeyword (wants 1 space between 'class' and '(') and Universal.WhiteSpace.AnonClassKeywordSpacing (wants no space). The two rules contradict each other, so any anonymous class declaration fails CS regardless of how it is written. Exclude the PSR12 rule so the Universal sniff wins, unblocking CI on this PR. --- phpcs.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/phpcs.xml b/phpcs.xml index 3cf4581..759198c 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -8,7 +8,10 @@ */vendor/* - + + + + From 43f9e69f0ab43510af18de22d7eaa2830be05092 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 14 May 2026 00:15:05 +0200 Subject: [PATCH 3/3] Add missing space in anonymous class declaration PhpCollective code-sniffer now configures Universal.WhiteSpace.AnonClassKeywordSpacing with spacing=1, matching the existing PSR12.Classes.AnonClassDeclaration.SpaceAfterKeyword rule. The two rules agree on '1 space between class and (' so the prior exclude is no longer needed; revert it and apply the actual one-character source fix. --- phpcs.xml | 5 +---- tests/TestCase/Extension/HeadingReferenceExtensionTest.php | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/phpcs.xml b/phpcs.xml index 759198c..3cf4581 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -8,10 +8,7 @@ */vendor/* - - - - + diff --git a/tests/TestCase/Extension/HeadingReferenceExtensionTest.php b/tests/TestCase/Extension/HeadingReferenceExtensionTest.php index ef5ec00..15cfcc6 100644 --- a/tests/TestCase/Extension/HeadingReferenceExtensionTest.php +++ b/tests/TestCase/Extension/HeadingReferenceExtensionTest.php @@ -251,7 +251,7 @@ public function testHeadingWithNoTextIsIgnored(): void public function testUserAuthoredLinkWithMatchingPlaceholderIsNotRewritten(): void { - $extension = new class('heading-ref') extends HeadingReferenceExtension { + $extension = new class ('heading-ref') extends HeadingReferenceExtension { protected function generatePlaceholderPrefix(): string { return 'collision-placeholder-';