Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions docs/reference/enhancements.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,13 @@ Auto-generated heading IDs follow the settled [jgm/djot#393](https://github.com/

### Normalization Rules (default)

1. **Replace non-alphanumeric ASCII** — each maximal run of non-alphanumeric ASCII (spaces, punctuation, `_`, runs of `-`) becomes a single `-`.
2. **Trim** leading/trailing `-`. **Letter case and all non-ASCII characters (accented Latin, Cyrillic, CJK, smart quotes, …) are preserved.**
3. **Prefix `s-` for a leading digit** — a leading digit is a valid HTML id but an invalid *bare* CSS selector (`querySelector('#9-x')` throws), so it is prefixed. Orthogonal to #393, which governs punctuation only.
4. **Fallback** — an empty result (all-punctuation text) becomes a generated `s-N` id.
1. **Reverse smart punctuation to its ASCII source** — the parser renders apostrophes/quotes/dashes/ellipsis as typographic glyphs (`'` → U+2019, `--` → en dash, `...` → ellipsis, …). These are mapped back to their ASCII source *before* slugging, so the id is derived from the source text and never carries a curly quote or dash glyph. This matches the djot.js reference, which builds ids from the source text.
2. **Replace non-alphanumeric ASCII** — each maximal run of non-alphanumeric ASCII (spaces, punctuation, `_`, runs of `-`) becomes a single `-`.
3. **Trim** leading/trailing `-`. **Letter case and genuine non-ASCII characters (accented Latin, Cyrillic, CJK, …) are preserved.**
4. **Prefix `s-` for a leading digit** — a leading digit is a valid HTML id but an invalid *bare* CSS selector (`querySelector('#9-x')` throws), so it is prefixed. Orthogonal to #393, which governs punctuation only.
5. **Fallback** — an empty result (all-punctuation text) becomes a generated `s-N` id.

> Only the core, locale-independent glyphs (apostrophe, straight-quote pair, en/em dash, ellipsis) are reversed. Locale quote glyphs from the opt-in `SmartQuotesExtension` (guillemets, low-9 quotes) are left as-is, and a literal author-typed typographic glyph in the source is treated the same as a parser-generated one.

Symbols (`:name:`) and footnote references are excluded from the id text (see [Section ID Excludes Footnote Markers and Symbols](#section-id-excludes-footnote-markers-and-symbols)).

Expand All @@ -183,6 +186,9 @@ Symbols (`:name:`) and footnote references are excluded from the id text (see [S
| `# café résumé` | `café-résumé` |
| `# Привет мир` | `Привет-мир` |
| `# under_score` | `under-score` |
| `# Bob's Guide` | `Bob-s-Guide` |
| `# Say "Hello"` | `Say-Hello` |
| `# Pros -- and cons` | `Pros-and-cons` |
| `# E=mc^2` | `E-mc-2` |
| `# 123 Numbers First` | `s-123-Numbers-First` |
| `# $this->method()` | `this-method` |
Expand Down
47 changes: 43 additions & 4 deletions src/Renderer/HeadingIdTracker.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,18 @@ public function reserveExplicitIds(Node $node): void
/**
* Normalize heading text into an identifier (jgm/djot#393)
*
* 1. Slug the text: replace each maximal run of non-alphanumeric ASCII with a
* 1. Reverse the parser's smart-punctuation back to its ASCII source
* (`'`, `"`, `--`, `...`), so the id is derived from the source text
* rather than the presentational glyphs. The djot.js reference builds
* ids from the source (its smart_punctuation nodes carry the ASCII
* text), so `# Bob's Guide` yields an id from `Bob's Guide`, not from
* the rendered `Bob<U+2019>s Guide`.
* 2. Slug the text: replace each maximal run of non-alphanumeric ASCII with a
* single '-' and trim; non-ASCII characters and letter case are preserved.
* 2. If an id transform is set (e.g. ASCII transliteration via
* 3. If an id transform is set (e.g. ASCII transliteration via
* AsciiHeadingIdsExtension), apply it to the slug and re-slug the result
* (the transform may reintroduce spaces/punctuation, e.g. romanization).
* 3. Prefix with 's-' if the result starts with a digit, so the id is a valid
* 4. Prefix with 's-' if the result starts with a digit, so the id is a valid
* bare CSS selector (querySelector('#9-x') would otherwise throw). This is
* orthogonal to #393, which governs punctuation only.
*
Expand All @@ -147,7 +153,7 @@ public function reserveExplicitIds(Node $node): void
*/
public function normalizeId(string $text): string
{
$id = $this->slug($text);
$id = $this->slug($this->deTypography($text));

if ($this->idTransformer !== null) {
$id = $this->slug(($this->idTransformer)($id));
Expand All @@ -169,6 +175,39 @@ protected function slug(string $text): string
return trim(preg_replace('/[^0-9A-Za-z\x{0080}-\x{10FFFF}]+/u', '-', $text) ?? $text, '-');
}

/**
* Reverse the parser's default smart-punctuation substitutions to their ASCII
* source before an id is slugged, so an id never depends on presentational
* typography. The InlineParser always renders apostrophes as U+2019 and
* converts `"`, `--`, `---` and `...` to their typographic forms; without this
* step those non-ASCII glyphs survive slug() (>= U+0080 is preserved) and leak
* into the id (e.g. `Bob<U+2019>s-Guide` instead of `Bob-s-Guide`).
*
* Only the core, locale-independent glyphs are mapped here. Locale quote glyphs
* from the opt-in SmartQuotesExtension (guillemets, low-9 quotes) are not
* reversed, since those code points can also be legitimate heading content.
*
* Limitation: the parser bakes smart punctuation into plain text nodes, so a
* literal author-typed glyph (e.g. a real U+2013 in the source) is
* indistinguishable from a parser-generated one and is reversed the same way.
* The djot.js reference avoids this by carrying the source on dedicated
* smart-punctuation nodes; matching that would need node-level source tracking.
* In practice the collapse only affects rare literal-typographic headings and is
* consistent with the sibling carve-php implementation.
*/
protected function deTypography(string $text): string
{
return strtr($text, [
"\u{2018}" => "'", // left single quotation mark
"\u{2019}" => "'", // right single quotation mark / apostrophe
"\u{201C}" => '"', // left double quotation mark
"\u{201D}" => '"', // right double quotation mark
"\u{2013}" => '--', // en dash (source: --)
"\u{2014}" => '---', // em dash (source: ---)
"\u{2026}" => '...', // horizontal ellipsis (source: ...)
]);
}

/**
* Get plain text content of a node
*
Expand Down
10 changes: 5 additions & 5 deletions tests/TestCase/Extension/HeadingReferenceExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,16 +173,16 @@ public function testHeadingWithSmartQuotesMatchesStraightQuoteReference(): void
$converter->addExtension(new HeadingReferenceExtension());

// The parser converts straight quotes to smart quotes in heading text,
// but reference targets keep straight quotes. The extension normalizes
// quotes for matching so the reference still resolves; the resulting id
// keeps the smart quotes (jgm/djot#393 preserves non-ASCII characters).
// but reference targets keep straight quotes. The id is derived from the
// ASCII source (smart punctuation is reversed before slugging, matching
// the djot.js reference), so both sides resolve to the same glyph-free id.
$html = $converter->convert(<<<'DJOT'
See [[Say "Hello"]].

# Say "Hello"
DJOT);

$this->assertStringContainsString('href="#Say-Hello"', $html);
$this->assertStringContainsString('href="#Say-Hello"', $html);
$this->assertStringNotContainsString('[[Say "Hello"]]', $html);
}

Expand Down Expand Up @@ -211,7 +211,7 @@ public function testHeadingWithApostropheResolvesCorrectly(): void
# Bob's Guide
DJOT);

$this->assertStringContainsString('href="#Bobs-Guide"', $html);
$this->assertStringContainsString('href="#Bob-s-Guide"', $html);
$this->assertStringNotContainsString('data-heading-ref=', $html);
$this->assertStringNotContainsString('[[Bob\'s Guide]]', $html);
}
Expand Down
52 changes: 47 additions & 5 deletions tests/TestCase/Renderer/HeadingIdTrackerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Djot\Test\TestCase\Renderer;

use Djot\DjotConverter;
use Djot\Node\Block\Heading;
use Djot\Node\Inline\FootnoteRef;
use Djot\Node\Inline\HardBreak;
Expand Down Expand Up @@ -185,17 +186,22 @@ public function testNormalizeId(): void
/**
* Pins djot-php's heading-ID behaviour to jgm/djot#393: each maximal run of
* non-alphanumeric ASCII is replaced with `-` and leading/trailing `-` are
* trimmed. Case and non-ASCII characters (Cyrillic, accented Latin, smart
* quotes) are preserved; `_` is replaced (no longer an exception). A
* leading-digit result keeps the `h-` prefix for CSS-selector safety
* (orthogonal to #393). ASCII-folding is opt-in via AsciiHeadingIdsExtension.
* trimmed. Case and genuine non-ASCII characters (Cyrillic, accented Latin)
* are preserved; `_` is replaced (no longer an exception). The parser's smart
* punctuation is reversed to its ASCII source first, so a smart apostrophe/
* quote becomes a separator (matching the djot.js reference, which slugs the
* source text) rather than a preserved U+2019 glyph. A leading-digit result
* keeps the `s-` prefix for CSS-selector safety (orthogonal to #393).
* ASCII-folding is opt-in via AsciiHeadingIdsExtension.
*/
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('That’s-all', $this->tracker->normalizeId('That’s all'));
// Smart apostrophe (U+2019) is reversed to its ASCII source, so it slugs
// like a straight apostrophe instead of leaking the glyph into the id.
$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'));
Expand All @@ -205,6 +211,42 @@ public function testNormalizeIdSpecAlignmentEdgeCases(): void
$this->assertSame('', $this->tracker->normalizeId('!!!'));
}

/**
* The parser renders smart punctuation into the heading text (apostrophe as
* U+2019, quotes/dashes/ellipsis as their typographic glyphs). Those glyphs
* are reversed to their ASCII source before slugging so the id is derived
* from the source text (as djot.js does), rather than leaking non-ASCII
* typography into the id.
*/
public function testNormalizeIdReversesSmartPunctuation(): void
{
$this->assertSame('Bob-s-Guide', $this->tracker->normalizeId("Bob\u{2019}s Guide"));
$this->assertSame('left-single', $this->tracker->normalizeId("\u{2018}left single\u{2019}"));
$this->assertSame('Say-Hello', $this->tracker->normalizeId("Say \u{201C}Hello\u{201D}"));
$this->assertSame('a-b', $this->tracker->normalizeId("a \u{2013} b"));
$this->assertSame('a-b', $this->tracker->normalizeId("a \u{2014} b"));
$this->assertSame('foo-bar', $this->tracker->normalizeId("foo\u{2026}bar"));
}

/**
* End-to-end: a heading run through the full converter (which applies smart
* punctuation) must still produce a source-derived id, free of typographic
* glyphs.
*/
public function testSmartPunctuationIdEndToEnd(): void
{
$converter = new DjotConverter();

$html = $converter->convert("# That's all");
$this->assertStringContainsString('id="That-s-all"', $html);

$html = $converter->convert('# a -- b');
$this->assertStringContainsString('id="a-b"', $html);

$html = $converter->convert('# Say "Hello"');
$this->assertStringContainsString('id="Say-Hello"', $html);
}

/**
* An id transform (e.g. the one set by AsciiHeadingIdsExtension) is applied to
* the spec id; here it transliterates non-ASCII to ASCII for portability.
Expand Down
Loading