polytypo 1.6.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +25 -0
  3. data/lib/polytypo/data/.not-vendored +11 -0
  4. data/lib/polytypo/data/README.md +16 -11
  5. data/lib/polytypo/data/VERSION +1 -1
  6. data/lib/polytypo/data/fixtures/cs.json +1 -1
  7. data/lib/polytypo/data/fixtures/de-CH.json +1 -1
  8. data/lib/polytypo/data/fixtures/de-DE.json +14 -1
  9. data/lib/polytypo/data/fixtures/el.json +1 -1
  10. data/lib/polytypo/data/fixtures/en-GB.json +1 -1
  11. data/lib/polytypo/data/fixtures/en-US.json +521 -1
  12. data/lib/polytypo/data/fixtures/es.json +1 -1
  13. data/lib/polytypo/data/fixtures/fi.json +1 -1
  14. data/lib/polytypo/data/fixtures/fr-CA.json +1 -1
  15. data/lib/polytypo/data/fixtures/fr.json +68 -1
  16. data/lib/polytypo/data/fixtures/it.json +1 -1
  17. data/lib/polytypo/data/fixtures/locale-resolution.json +1 -1
  18. data/lib/polytypo/data/fixtures/nl.json +1 -1
  19. data/lib/polytypo/data/fixtures/pl.json +1 -1
  20. data/lib/polytypo/data/fixtures/pt-BR.json +1 -1
  21. data/lib/polytypo/data/fixtures/pt-PT.json +1 -1
  22. data/lib/polytypo/data/fixtures/ru.json +1 -1
  23. data/lib/polytypo/data/fixtures/sv.json +1 -1
  24. data/lib/polytypo/data/fixtures/tr.json +1 -1
  25. data/lib/polytypo/data/fixtures/uk.json +1 -1
  26. data/lib/polytypo/data/locales/cs.json +56 -37
  27. data/lib/polytypo/data/locales/de-CH.json +43 -34
  28. data/lib/polytypo/data/locales/de-DE.json +47 -32
  29. data/lib/polytypo/data/locales/el.json +51 -1
  30. data/lib/polytypo/data/locales/en-GB.json +56 -4
  31. data/lib/polytypo/data/locales/en-US.json +68 -4
  32. data/lib/polytypo/data/locales/es.json +66 -29
  33. data/lib/polytypo/data/locales/fi.json +77 -7
  34. data/lib/polytypo/data/locales/fr-CA.json +63 -36
  35. data/lib/polytypo/data/locales/fr.json +70 -41
  36. data/lib/polytypo/data/locales/it.json +65 -22
  37. data/lib/polytypo/data/locales/nl.json +61 -29
  38. data/lib/polytypo/data/locales/pl.json +61 -28
  39. data/lib/polytypo/data/locales/pt-BR.json +53 -28
  40. data/lib/polytypo/data/locales/pt-PT.json +55 -28
  41. data/lib/polytypo/data/locales/registry.json +1 -1
  42. data/lib/polytypo/data/locales/ru.json +45 -30
  43. data/lib/polytypo/data/locales/sv.json +63 -4
  44. data/lib/polytypo/data/locales/tr.json +72 -32
  45. data/lib/polytypo/data/locales/uk.json +55 -27
  46. data/lib/polytypo/data/rules/modes.md +430 -11
  47. data/lib/polytypo/data/rules/order.json +1 -1
  48. data/lib/polytypo/data/schema/fixtures.schema.json +12 -1
  49. data/lib/polytypo/engine/pipeline.rb +29 -0
  50. data/lib/polytypo/modes/markdown.rb +256 -31
  51. data/lib/polytypo/modes/runner.rb +33 -3
  52. data/lib/polytypo/version.rb +1 -1
  53. data/lib/polytypo.rb +27 -12
  54. metadata +2 -1
@@ -7,10 +7,13 @@ for all five runtimes and is parser-agnostic by construction: `parse5`, `nokogir
7
7
  `golang.org/x/net/html` and PHP's DOM disagree about almost everything this document does not
8
8
  forbid them from doing. `yaml` mode is parser-**free** rather than parser-agnostic, for the
9
9
  reason §3.8.1 measures.
10
- **Spec version:** 1.5.0 (0.1.0 for everything except §3.3's class-membership table rows for
10
+ **Spec version:** 1.8.0 (0.1.0 for everything except §3.3's class-membership table rows for
11
11
  `nbsp` and `apostrophe`, split in 1.2.0, §3.8, added in 1.3.0, §3.3's note on `quotes`'
12
12
  span-boundary elision veto reading the marker as a trigger, added in 1.4.0 — which changes no
13
- row of the table it follows — and §3.3's `CLOSEDELIM` entry, added in 1.5.0).
13
+ row of the table it follows — §3.3's `CLOSEDELIM` entry, added in 1.5.0, and §3.7.4 with the
14
+ amendments it carries to §3.1's definitions, §3.2's Model C, §3.5 step 3, §3.7.3's frontmatter
15
+ bullet, §3.8.6's accepted-cost paragraph, §5, §6 and §7, added in 1.7.0, and §3.7.3a, added in
16
+ 1.8.0).
14
17
 
15
18
  ---
16
19
 
@@ -49,6 +52,10 @@ sees; the pipeline decides what to do with them.
49
52
  identified by its offsets in the **original source**, and those offsets are the only handle
50
53
  the mode layer keeps.
51
54
  - The **span sequence** `S₁ … Sₘ` is the processable spans in document order.
55
+ - A **text unit** is one span sequence and the array built from it (§3.5 step 2). A document has
56
+ exactly one, **except** in `markdown` with `frontmatterKeys` (§3.7.4, spec 1.7.0), where the
57
+ frontmatter block's spans form a second unit and the pipeline runs once per unit. The term is
58
+ defined here because §4 and §6 already used it informally for "the concatenation".
52
59
  - `text` mode is the degenerate case: one span covering the whole input, no skipped regions.
53
60
  Every statement below holds for it trivially.
54
61
 
@@ -88,7 +95,9 @@ each rule is behaving exactly as specified on the input it was given.
88
95
 
89
96
  **Model C — concatenation with an explicit boundary marker. Adopted.** The spans are
90
97
  concatenated with a **boundary marker** between each adjacent pair. The pipeline runs once, over
91
- the whole marker-separated array. Edits are then redistributed to spans by offset.
98
+ the whole marker-separated array — once per **text unit** (§3.1), which for every mode but
99
+ `markdown` with `frontmatterKeys` (§3.7.4) means once per document. Edits are then
100
+ redistributed to spans by offset.
92
101
 
93
102
  The marker gives the rules what Model A denies them — the knowledge that `'hi'` sits inside a
94
103
  larger quotation — while denying them what Model B wrongly grants: the belief that the last
@@ -365,7 +374,9 @@ reaches the edge-growth rule. `--` is the live carrier.)
365
374
  gives flow collections no spans — see `quotes.md` §3.2, which depends on the answer for
366
375
  nothing but states the obligation it creates: a rule must key off the marker, never off the
367
376
  mode.
368
- 3. Run the pipeline **once**, in `order.json` order, over that array.
377
+ 3. Run the pipeline **once**, in `order.json` order, over that array. A document with a second
378
+ text unit — `markdown` with `frontmatterKeys`, and only that (§3.1, §3.7.4) — repeats steps 1
379
+ to 4 for it. The two edit sets are disjoint, because no span of one unit lies inside the other.
369
380
  4. Each edit lies wholly within one span (§3.4). Map it back to source offsets.
370
381
  5. Emit the **original source bytes**, with those replacements applied and nothing else changed.
371
382
 
@@ -477,7 +488,9 @@ Two constraints on the throw:
477
488
  Skipped, exhaustively:
478
489
 
479
490
  - **frontmatter** — a metadata block at the very start of the document, delimited by `---`
480
- (YAML) or `+++` (TOML), skipped whole including its delimiters. Without it the closing `---`
491
+ (YAML) or `+++` (TOML), skipped whole including its delimiters. **Since 1.7.0 a caller may
492
+ name keys inside a YAML block to process — §3.7.4 — and the skip stands unchanged when they
493
+ do not.** Without it the closing `---`
481
494
  reads as a setext underline, `title: Une note` becomes a paragraph, and `fr` inserts a narrow
482
495
  no-break space before the colon of a machine-read field. That is a guaranteed false positive
483
496
  on the M4 corpus (PLAN.md §8), where every file opens with frontmatter;
@@ -512,6 +525,349 @@ lower-cases JSX names before matching will skip a component's children.
512
525
  Nesting follows the same rule as `html`: a skipped construct is skipped whole, including
513
526
  anything that looks processable inside it.
514
527
 
528
+ #### 3.7.3a Where the frontmatter block begins and ends
529
+
530
+ **Spec 1.8.0.** §3.7.3 skips "a metadata block at the very start of the document, delimited by
531
+ `---` (YAML) or `+++` (TOML)". That is prose, and frontmatter is in neither CommonMark nor GFM,
532
+ so until now each runtime reached the construct through its own parser's frontmatter support —
533
+ four extensions, four answers. Measured on the published 1.7.0 line, `en-US`, `commonmark`,
534
+ asking only whether the block is skipped or typeset as prose:
535
+
536
+ | document | JS | Python | Go | Ruby |
537
+ | ------------------------------------------- | ------- | ------- | ----------- | ----------- |
538
+ | `---` / `title: "x - y"` / `---` | skipped | skipped | skipped | skipped |
539
+ | trailing space on the **opening** delimiter | skipped | skipped | **typeset** | **typeset** |
540
+ | trailing space on the **closing** delimiter | skipped | skipped | **typeset** | **typeset** |
541
+ | `...` as the closer | typeset | typeset | typeset | typeset |
542
+ | `---` after a leading blank line | typeset | typeset | typeset | typeset |
543
+
544
+ Two of four turn `date: "2026-09-26"` into a quoted-and-curled string over one trailing space
545
+ that no author typed on purpose, and both were conformant, because no fixture pinned the edge.
546
+
547
+ > **The block's extent is decided by the scan below, not by a parser's frontmatter support.** A
548
+ > runtime whose parser also recognises the construct must produce the same extent as this scan; the
549
+ > scan is what a fixture pins and what a disagreement is measured against.
550
+ >
551
+ > **And the parser is handed the block masked out.** The source given to the Markdown parser is the
552
+ > document with the located block — both delimiter lines included, **and the leading U+FEFF of step
553
+ > 1 if there is one** — replaced by U+0020, line terminators kept as they are, so that nothing
554
+ > inside the block can form or close a construct in the body.
555
+ >
556
+ > **The masked source must be positionally aligned with the original in the unit the runtime maps
557
+ > parser offsets back through.** That is the invariant, and it is not the same as "one U+0020 per
558
+ > code point": a runtime that hands its parser's byte offsets straight through owes byte-length
559
+ > preservation, and masking `😀` to a single space shortens its source by three and shifts every
560
+ > body offset after it. A runtime that converts offsets against the **masked** source before
561
+ > reading them against the original owes code-point alignment only, which one U+0020 per code point
562
+ > gives it — and in a language whose strings are sequences of code points, byte-length preservation
563
+ > cannot even be expressed. Both are conformant; stating it as an index-unit count was not, and the
564
+ > port that indexes code points while its parser counts bytes is what showed it.
565
+ >
566
+ > **And no span may lie inside the block, whatever the parser did with the masked text.** Masking
567
+ > is what makes that true for most parsers and it is not sufficient for all of them: measured,
568
+ > tree-sitter-markdown reads a final all-space line with no terminator as a paragraph, so a
569
+ > document whose closing `---` ends the file comes back with a span over the delimiter itself —
570
+ > the parser did not see the block at all, and step 5's "end of input ends a line" is the clause it
571
+ > does not implement. A runtime whose parser emits such a span **clips it to the part outside the
572
+ > block, and drops it when nothing is left**: the block's own characters must not reach the rules,
573
+ > and body prose past the block must not be lost to a parser's mistake about where the block ended.
574
+ > The mask is there so the parse is not deformed; this rule is there so the spans cannot be wrong
575
+ > even when it is.
576
+ >
577
+ > The mark is masked with the block because leaving it out breaks both: a first line of U+FEFF
578
+ > followed by spaces is not blank, no parser is required to strip the mark, and goldmark does not.
579
+
580
+ Masking is not an implementation note, and the runtime that skipped it is measured. Suppressing a
581
+ span inside the block's range is not enough, because the parser has already read the block's
582
+ characters by then: a fenced-code line inside a metadata value pairs with the body's own fence, and
583
+ the body's code block and its prose swap places. One document, the same call in four runtimes, on
584
+ published 1.7.0:
585
+
586
+ ````
587
+ ---
588
+ x: |
589
+ ```
590
+ ---
591
+
592
+ ```
593
+ code "q"
594
+ ```
595
+
596
+ Body "q".
597
+ ````
598
+
599
+ | runtime | result |
600
+ | ---------- | --------------------------------------------------------------------------- |
601
+ | JS, Python | `code "q"` stays straight, `Body “q”` converts — correct |
602
+ | Go | **`code “q”` is typeset inside the code block**, and `Body "q"` is missed |
603
+ | Ruby | correct with this bare-looking fence, wrong the moment the fence has a space |
604
+
605
+ Go reaches that by parsing the whole source and suppressing spans in the block's range, which is
606
+ the obvious way to do it without a frontmatter-aware parser and is wrong for a reason no span-level
607
+ rule can see: the damage is in what the parser concluded, not in which spans were emitted. Masking
608
+ costs one pass over a known range and removes the class.
609
+
610
+ **The scan**, over the source's code-point array, in §3.8.4's terms:
611
+
612
+ 1. the document must **begin** with the delimiter — `---` or `+++` at offset 0, no leading blank
613
+ line and no indentation. **A single leading U+FEFF is stepped over first** and is not part of
614
+ the document for this scan. It is a byte-order mark, not content: every editor that writes one
615
+ writes it before the fence, and reading it as content would deny the block to every file some
616
+ Windows editors produce. Measured on 1.7.0, JS and Python already step over it and Go and Ruby
617
+ do not — the same two-against-two split, on the same damaging side, as the trailing space;
618
+ 2. the rest of that line must be only U+0020 and U+0009. Anything else and there is no block, and
619
+ what the line then is belongs to the dialect rather than to this scan: `--- yaml` is not a
620
+ thematic break, since a break admits only spaces and tabs after its run;
621
+ 3. the **closing line** is the first later line whose first code point begins the same delimiter,
622
+ followed by only U+0020 and U+0009. Indentation disqualifies it exactly as it disqualifies the
623
+ opening line — a closer is not searched for inside a line, it is a line. `...` is not a closer
624
+ in either matter, a delimiter of the other kind is not one either, and a fourth delimiter
625
+ character is not whitespace, so `----` closes nothing;
626
+ 4. with no such line there is **no block**, and the opening delimiter is whatever the dialect makes
627
+ of it — a thematic break for `---`, ordinary paragraph text for `+++` — with everything after it
628
+ prose, which is what `en-us-markdown-commonmark-frontmatter-unterminated` already pins;
629
+ 5. a **line ends as CommonMark ends one** — at U+000A, at a U+000D that is not followed by
630
+ U+000A, or at the end of input — and the terminator is never part of the line. So a CRLF
631
+ document gives the same block as the same bytes with LF, a file whose last line is `---\r`
632
+ with no final U+000A still closes its block, and a document written with lone U+000D line
633
+ endings has one at all.
634
+
635
+ **This is the Markdown document's line model, and it is deliberately not §3.8.4's.** The block
636
+ is a Markdown construct, so it ends its lines the way the language around it does. The content
637
+ inside it keeps §3.8.4's LF-only model — and the reason is not that YAML agrees, because it
638
+ does not: YAML 1.2.2 §5.4 admits a lone U+000D as a line break too. §3.8.4 is a deliberate
639
+ simplification, taken and measured in 1.3.0, and this section does not widen it, because
640
+ widening the content scan's line model is a change to `yaml` mode for every caller and wants
641
+ its own measurement. A port that harmonises the two has silently made that change. What it
642
+ costs here is recorded in §7.13.
643
+
644
+ All three shapes above are documents whose metadata a stricter reading hands to the rules, and
645
+ the reference runtime already treats all three as blocks — measured on 1.7.0.
646
+
647
+ **Which way to be wrong, and why this way.** A locator errs in one of two directions and they are
648
+ not the same size. Recognising a block that is not one skips text that was prose: a miss, and
649
+ §3.8.3 already accepts misses by the dozen. Failing to recognise a block that is one hands the
650
+ metadata to the rules as prose: `title: Q3 review: what changed` takes a no-break space before
651
+ its colon in `fr` — U+00A0, since `fr` puts `:` in `beforePunctuation` and only `;`, `!` and `?`
652
+ in the narrow list — and a date grows quotation marks. The first is invisible and harmless;
653
+ the second is the damage §3.7.3 exists to prevent. **So the wider reading wins every edge where
654
+ the runtimes disagree**, and the trailing-space clause of steps 2 and 3 is that reading written
655
+ down.
656
+
657
+ The accepted cost is stated rather than hidden: a document whose very first line is a thematic
658
+ break written as `--- `, followed by prose and another `---` line, has **everything up to that
659
+ line** skipped — not one paragraph but the whole first section, heading included, since the scan
660
+ takes the first later delimiter line whatever lies between. It is skipped by JS and Python today,
661
+ has been for every release, and nobody has reported it — while a frontmatter block carrying
662
+ trailing whitespace on its fence is what any editor that trims nothing produces.
663
+
664
+ **What this costs each runtime.** JS and Python already behave this way, so the change there is
665
+ that the extent stops being their parser's opinion and becomes this scan's — which also removes
666
+ the second parse `frontmatterKeys` cost them (polytypo/polytypo#59), since locating the block no
667
+ longer needs one. Go and Ruby change behaviour: both must widen their locator to admit trailing
668
+ U+0020 and U+0009 on either delimiter. Go already owns a hand-written locator for exactly this
669
+ reason and `detectFrontmatter` is where it lives.
670
+
671
+ #### 3.7.4 Frontmatter by named keys — the one opt-out from §3.7.3
672
+
673
+ **Spec 1.7.0.** §3.7.3 skips the frontmatter block whole and its reason for doing so still holds.
674
+ It is also the one place where the default hides the sentence most readers of the page will see:
675
+ where frontmatter carries `title`, `description` and `summary`, those strings are the heading,
676
+ the `<title>`, the meta description and the card text, so the single most visible string on the
677
+ page is the one polytypo will not touch. Both reports that asked for this — polytypo/polytypo#13,
678
+ and the production integration in #26 over 196 MDX posts, independently — hand-rolled it instead,
679
+ and the first of them corrupted its own content doing so: its extractor knew double-quoted YAML
680
+ scalars and silently skipped every single-quoted one.
681
+
682
+ > **`markdown` mode takes an optional `frontmatterKeys` option: the mapping keys in the
683
+ > document's YAML frontmatter block whose scalar values are processable.** Absent means
684
+ > §3.7.3 unchanged — the block is skipped whole, delimiters included. An **empty list is
685
+ > legal** and yields no spans, as it does for `keys` (§3.8.2). A value that is not a list of
686
+ > strings throws `POLYTYPO_INVALID_OPTION`.
687
+
688
+ **Ratified by the operator as public contract, 2026-09-25**, under this name rather than by
689
+ widening `keys`. Three reasons, in the order that decided it: `keys` is **required** in `yaml`
690
+ mode and optional here, so one name would carry two requiredness contracts; `polytypo` 1.6.3
691
+ **accepts a `keys` it ignores** in `markdown` mode — measured, not assumed — so reusing the name
692
+ would silently begin typesetting frontmatter for any caller who passes one options object to both
693
+ modes; and the name says which block it governs, which a bare `keys` on a mode whose body is also
694
+ full of keys does not.
695
+
696
+ **What the option governs, in source offsets.** The block is the frontmatter construct §3.7.3
697
+ already recognises, and the option only changes what happens inside it:
698
+
699
+ - the **content** is the source from the code point after the opening delimiter line's
700
+ terminator to the code point that begins the closing delimiter line. A U+000D before that
701
+ terminator belongs to the terminator, exactly as in §3.8.4, so a CRLF document and the same
702
+ bytes with LF give the same content;
703
+ - **a line of that content containing a U+000D not followed by U+000A yields no spans**, exactly
704
+ as §3.8.4 step 1 already declines a line containing U+0009 and for the same kind of reason. The
705
+ two line models meet here and do not compose: §3.7.3a step 5 finds the block in a lone-U+000D
706
+ document, and §3.8.4's LF-only scan then reads that whole block as one line. Measured, the
707
+ result of letting it through is not merely inert — `title: a "b` and `c" d` on two mapping
708
+ lines pair their marks across the boundary, an unlisted line inside the listed key's scalar
709
+ takes `fr`'s spacing, and the U+000D lands **inside a span**, which §3.8.4 forbids in the same
710
+ breath. Per line rather than per block, because a stray U+000D inside one quoted value is
711
+ something people produce by accident and it should cost that value rather than the whole block:
712
+ a lone-U+000D document is one §3.8.4 line and loses everything, a document with one such value
713
+ loses that line and keeps the rest. **The test runs to the start of the next line, not to the
714
+ end of this one**, because §3.8.4's own splitter treats a trailing U+000D as a terminator even
715
+ without a U+000A after it — so a block whose single line ends in one looks clean if the
716
+ terminator is excluded, and one of the four lone-U+000D fixtures separates the two readings.
717
+ **The decline drops the spans the content scan produced for that line; it does not alter the
718
+ content the scan is given, and a span reaching a declined position is dropped whole rather than
719
+ trimmed.** Three ports reached for the shortcut of substituting the U+000D for another
720
+ character the scan already declines, and it is not equivalent: substitution moves the character
721
+ to a different line and can end a value run, so `title` / `slug` / a content-final U+000D
722
+ converts both keys instead of one, and a stray U+000D on a key whose value run continues onto
723
+ an indented line converts a key that is not a key. Both shapes are pinned, and both agree with
724
+ what `yaml` mode already does with the same characters — measured in two shipped runtimes.
725
+ **Where this rule and `yaml` mode part is on purpose, and it is one shape:** a content line
726
+ whose terminator is a bare U+000D is clean to `yaml` mode, which strips it, and declined here,
727
+ because the window includes it. So `title` / `slug` / a content-final U+000D converts `slug` in
728
+ `yaml` mode and does not here. The wider decline is the direction this section takes everywhere
729
+ else — a miss is invisible, a U+000D inside a span is not — and the cost is conversions lost in
730
+ documents written with line endings from the last century. Widening §3.8.4 instead would be a
731
+ change to `yaml` mode for every caller and wants its own measurement;
732
+ - **both delimiter lines stay outside every span**, as does every line terminator, so no edit
733
+ can reach `---` itself and §3.7.3's setext-underline hazard is unreachable;
734
+ - an **unterminated** block is not a block — §3.7.3 already yields no frontmatter construct
735
+ there, and the option adds no spans to it;
736
+ - a **TOML block (`+++`) yields no spans, with the option given or not.** TOML's quoting is a
737
+ second grammar — basic strings escape with U+005C, literal strings do not escape at all, and
738
+ both have multi-line forms — and §3.8.3's posture applies: a construct the scan cannot claim
739
+ with certainty yields no spans. Neither report asked for TOML and no corpus measured here
740
+ contains one, so specifying a TOML locator would be scope taken on speculation. Recorded as an
741
+ accepted miss in §7.13.
742
+
743
+ **The option adds spans only where §3.7.3's skip removed them.** If there is no block — an
744
+ unterminated one, a `---` that is not at the start of the document, an opening line carrying
745
+ anything but whitespace — the text is ordinary prose in the body's own unit and the option
746
+ contributes nothing. That coupling is what makes double processing unreachable: no source position
747
+ can belong to both units — **and since 1.8.0 it is §3.7.3a's mask and its no-span rule that enforce
748
+ it**, not an agreement between two locators. Measured while that mask was still being specified, a
749
+ block whose closer the body's parser did not accept had its content emitted twice, once by each
750
+ unit: `more: b - c` came back as `more: b—cb—c`. **Since 1.8.0 the block's extent is §3.7.3a's
751
+ scan** rather than whatever each parser's frontmatter support decided, so the option no longer
752
+ inherits a variance that was measured in eleven documents out of twenty.
753
+
754
+ **Key matching is §3.8.2's, which means bare names at any depth.** `title` is processable wherever
755
+ it occurs in the block, `seo.title` included — measured: `seo:` then an indented `title:` is
756
+ processed under `frontmatterKeys: ["title"]` — with the cost §7.12 already accepts for `keys`: no
757
+ paths, no globs, so a caller with a machine-read `title` nested somewhere must either take it too
758
+ or name none. Matching is exact, code point for code point, with no case folding.
759
+
760
+ **The scan is §3.8's, unchanged.** §3.8.4 steps 1–9, §3.8.5 and §3.8.6 apply to the block content
761
+ verbatim, with `frontmatterKeys` as step 8's key predicate in place of `keys`. Nothing about YAML
762
+ is specified twice: frontmatter **is** YAML, and the argument that made §3.8 a hand-written scan
763
+ rather than a parser call (§3.8.1 — two of the five ecosystems' libraries cannot report an end
764
+ offset) applies here for the same reason and with the same measurements. Every miss §7.11 lists
765
+ is inherited with it, the quoted-escape bails included, and §7.13 gives what that costs on a real
766
+ corpus.
767
+
768
+ **The frontmatter block is its own text unit, and this is the part that is not obvious.** §3.2
769
+ concatenates a document's spans into one array and runs the pipeline once over it, and §7.10
770
+ records that a quotation opened in one paragraph can still pair with a mark in the next, because
771
+ the stack is not reset at a −2 marker. Measured on `polytypo` 1.6.3 in `yaml` mode, which has
772
+ exactly this shape:
773
+
774
+ ```
775
+ a: he said "hello → a: he said ‘hello
776
+ b: world" she said b: world’ she said
777
+ ```
778
+
779
+ Two spans, a −2 marker between them, and the marks paired across it. If frontmatter spans joined
780
+ the body's array, the same mechanism would let an unbalanced mark in `title` pair with one in the
781
+ first paragraph — and the body's output would then depend on the document's metadata.
782
+
783
+ > **The frontmatter block's spans form a text unit of their own.** The pipeline runs over
784
+ > that array and over the body's array separately; the two edit sets are disjoint by
785
+ > construction, since no span of one lies inside the other.
786
+
787
+ That is one more pipeline run per document and it buys a claim worth having, which a port can
788
+ test directly: **`frontmatterKeys` cannot change a byte outside the frontmatter block**, so every
789
+ case released before 1.7.0 keeps its recorded output — those cases set no option, and the body is
790
+ not reachable from one. The narrower claim is the true one: a released case's *block* would
791
+ convert if the option named a key in it. Measured, `fr-markdown-commonmark-frontmatter-nbsp`:
792
+ `title: Une note ; suite` takes its narrow no-break space under `frontmatterKeys: ["title"]`,
793
+ which is the whole point of the option and not a change to that case. The
794
+ asymmetry with `yaml` mode, where one document's keys do pair across each other, is deliberate:
795
+ there the whole document is data with prose in it, while here the block is metadata *about* a
796
+ document whose prose is the body, and the two are not one sentence in any document.
797
+
798
+ **The option applies to both dialects**, `commonmark` and `mdx`. YAML frontmatter is the same
799
+ construct in both, and §3.7.3 already lists it once for both.
800
+
801
+ **Validation.** `nbsp.md` §3.1a fixes the order through `dialect` — `mode` → `narrowNbsp` →
802
+ `rules` → `locale` → `dialect` — and `ARCHITECTURE.md`'s options table carries the whole of it.
803
+ `frontmatterKeys` joins that chain **after `dialect`**, and, like every option, is checked **before
804
+ the parse**. Both halves decide a case that is otherwise ambiguous. A call naming neither a valid
805
+ `dialect` nor a valid `frontmatterKeys` raises `POLYTYPO_INVALID_DIALECT`, because `dialect` is
806
+ first — and unlike `dialect` and `keys`, which belong to different modes and so never both apply,
807
+ these two do, which is what makes their order observable at all. A document that does not parse in
808
+ its dialect, called with a `frontmatterKeys` that is not a list of strings, raises
809
+ `POLYTYPO_INVALID_OPTION` and not `POLYTYPO_MALFORMED_INPUT`.
810
+
811
+ In `text`, `html` and `yaml` modes the option is **ignored and not validated**, exactly as
812
+ `dialect` is ignored in `text` and `html` (§3.7.1). That is the weaker of
813
+ the two choices and it is taken for consistency: a mode-specific option that throws in one mode and
814
+ is ignored in another teaches a caller nothing they can act on, and `dialect` set the precedent
815
+ before this option existed.
816
+
817
+ **What a fixture cannot express here**, the same gap `nbsp.md` §3.1a records for `narrowNbsp`: the
818
+ schema admits only a list of strings and only on a `markdown` case, so neither the throw above nor
819
+ the ignored-elsewhere rule has a fixture. Both are each runtime's own unit test, and the order in
820
+ this paragraph is what those tests assert.
821
+
822
+ ##### 3.7.4.1 What this was tested against
823
+
824
+ The corpus is **187 `.mdx` files** — the author's own site content, all of `content/`, of which the
825
+ 107 under `content/blog/` are the M4 corpus proper (PLAN.md §8). Every one of them opens with YAML
826
+ frontmatter and none with TOML. Keys `title`, `summary`,
827
+ `description`, `subtitle`, `quote`: **447 listed scalars**. Eight locales (`en-GB`, `en-US`,
828
+ `de-DE`, `fr`, `ru`, `es`, `sv`, `tr`), so 1496 file/locale cases per configuration.
829
+
830
+ Measured with `polytypo` 1.6.3's **`yaml` mode over the extracted block** — the scan this section
831
+ reuses — because `markdown` mode with the option exists in no runtime yet. The separate-unit rule
832
+ above is what makes that a faithful proxy rather than an approximation: the body cannot
833
+ participate.
834
+
835
+ Three configurations, 4488 cases — the corpus as authored, the corpus de-typeset, and the corpus
836
+ de-typeset with **every one of its 39 frontmatter keys** listed: **no byte changed outside a
837
+ listed scalar, no parse failure, no structural change, no change to an unlisted leaf, no
838
+ idempotency failure.** The de-typeset corpus is derived, and reported as derived: the site's
839
+ content is already typeset, so the characters polytypo inserts were folded back to their ASCII
840
+ originals to obtain input that has something to convert. It is a weaker corpus than found text,
841
+ and it is the only way this corpus can show a conversion at all.
842
+
843
+ - **The M4 bar holds as written.** As authored, in `en-GB` — the site's own locale — **0 of 187
844
+ files change**. Each of the other six changes 13 files and `fr` changes 117, every one of them
845
+ inside a listed scalar and from that locale's own conventions rather than from anything missed.
846
+ - **De-typeset, 247 of the 447 listed scalars convert** in `en-GB`, and none of the other 200
847
+ is a miss: the pipeline leaves them alone in `text` mode too.
848
+ - **The measurement is discriminating, which was checked rather than assumed.** The naive thing
849
+ a caller hand-rolls — the same blocks through `text` mode, no scan and no key list — damages
850
+ **every single case**: of 748 (187 files in `en-GB`, `de-DE`, `fr` and `ru`), **420 no longer
851
+ parse as YAML at all, 82 come back with different mapping keys, 246 with different values, and
852
+ none comes back unchanged.** The witnesses are ordinary: `fr` turns the key `name:` into
853
+ `name :`, and `en-GB` turns `slug: "pierre-moreau-architecture"` into
854
+ `slug: "‘pierre-moreau-architecture’"`. A harness reporting clean for both runs would prove
855
+ nothing; this one separates them completely.
856
+ - **What the key list protects here.** Listing all 39 keys converts four values the recommended
857
+ five do not, and two of them are damage rather than coverage: in `fr`, `seoTitle` gains a
858
+ narrow no-break space before the colon of a string written for a search engine, and a client
859
+ name `"HTPBE?"` becomes `"HTPBE ?"`. The other two are improvements a caller might well want
860
+ (`"Niamh O'Sullivan"` → `"Niamh O’Sullivan"`), which is the point: only the caller can tell
861
+ those apart, and that is the same argument §3.8.2 makes.
862
+ - **The inherited escape bail, measured.** Re-emitting each of the 247 convertible values in
863
+ one quoting style and running the scan over it: **single-quoted, 130 of 247 yield no spans**;
864
+ double-quoted, none do. The reason is §3.8.6's — an apostrophe inside a single-quoted scalar
865
+ is written `''`, which spells content with more characters than it has. Every listed scalar in
866
+ this corpus as written is double-quoted, so the bail never fires on it; a caller whose YAML
867
+ style is single quotes gets nothing on half of their prose, silently. That number is the
868
+ accepted cost of reusing §3.8's scan rather than a defect of this section, and §7.13 records it
869
+ where a reader will look for it.
870
+
515
871
  ### 3.8 Skip list — `yaml`
516
872
 
517
873
  **Spec 1.3.0.** `yaml` is the fourth mode id, and PLAN.md §3.2's "exactly three modes" is amended
@@ -785,7 +1141,9 @@ The length test of §3.4 separates exactly the two, with no knowledge of YAML
785
1141
  makes it bind rules not yet written, while a per-rule observation would not.
786
1142
 
787
1143
  The accepted cost is that a conversion whose replacement would **grow** against a colon or a hash
788
- is missed: `fr` inserts no narrow no-break space before a colon inside a YAML scalar, and
1144
+ is missed: `fr` inserts no narrow no-break space before a colon inside a **plain** YAML scalar —
1145
+ inside a quoted one the colon is content, no split applies and the space is inserted, which
1146
+ `fr-markdown-commonmark-frontmatter-keys-colon` pins — and
789
1147
  `one--two` takes its spaced dash only where the split leaves it interior to a span. That is the
790
1148
  same trade §7.3 already made for `mot<em>!</em>`, and in the same direction: a miss is visible to
791
1149
  the author and fixable in the source; a corrupted document is neither.
@@ -982,6 +1340,16 @@ they do not carry it over for free either. Write `M` for the whole mode transfor
982
1340
  > §3.8.2's `keys` option is the repair: the predicate reads the **key**, which lies outside
983
1341
  > every span and which no rule can reach, so the partition is a function of the source alone.
984
1342
 
1343
+ **`markdown` with `frontmatterKeys` (§3.7.4) inherits both**, and adds one obligation of its
1344
+ own that is discharged by the same observation. The block's spans are selected by §3.8's scan
1345
+ over YAML, so the four parts and the fifth hold verbatim — the predicate is `frontmatterKeys`
1346
+ against a key, and a key is outside every span. What is new is that the document now has **two
1347
+ units** rather than one, and `M` must partition it into the same two on the second run: the
1348
+ boundary between them is the frontmatter construct's own delimiter lines, which lie outside
1349
+ every span in either unit, so no edit can move, create or destroy one. A document whose
1350
+ frontmatter is processed therefore has a partition that is a function of the source alone,
1351
+ exactly as one whose frontmatter is skipped does.
1352
+
985
1353
  A content-dependent predicate is not merely risky here, it is unarguable: item 2's whole
986
1354
  method is to show that the characters rules may write and the positions they may write to are
987
1355
  disjoint from what decides structure. A predicate over span content puts the rules' own output
@@ -1004,7 +1372,9 @@ tests none of this document. In `yaml` the sweep alphabet must include `:`, `#`,
1004
1372
  as one token**, since those are the characters whose adjacency the argument above turns on and a
1005
1373
  three-dash run is not reachable from single dashes at a bounded payload length; and it must
1006
1374
  include U+0022 and U+005C, without which the sweep cannot reach a quoted scalar's delimiters at
1007
- all. §3.8.7 records what each run did and did not establish — including that a sweep comparing
1375
+ all. Since 1.7.0 the `markdown` run must also carry a template with a **frontmatter block and
1376
+ `frontmatterKeys` naming a key in it** — a document with two text units (§3.1) tests a composition
1377
+ the single-unit templates cannot reach. §3.8.7 records what each run did and did not establish — including that a sweep comparing
1008
1378
  structure and types is blind to a string whose content is damaged, and that an alphabet without
1009
1379
  `---` reported clean twice while §3.4's `r = d` hole was open.
1010
1380
 
@@ -1020,7 +1390,11 @@ means the claim holds in those modes only.
1020
1390
  **[P: html, markdown, yaml]** and is now _defined_ rather than assumed — §3.6, §3.7 and §3.8
1021
1391
  are what those bullets refer to. In `text` mode there are no skipped regions and the bullet is
1022
1392
  vacuous. In `yaml` the definition runs the other way round (§3.8.2): the skipped region is
1023
- everything the scan did not claim.
1393
+ everything the scan did not claim. Since 1.7.0 one region in `markdown` is skipped
1394
+ **conditionally** — the frontmatter block, whenever `frontmatterKeys` names a key in it
1395
+ (§3.7.4) — so the bullet is read against the options the call was made with. No rule's §4
1396
+ changes: the block is either skipped whole, as before, or decomposed by §3.8's scan, whose
1397
+ own claims §3.8 already states.
1024
1398
  - **`dashes` §4 "URLs, code spans, fenced code, HTML attributes"** — **[P: html, markdown]**.
1025
1399
  In `text` mode a URL is ordinary text. The rule is nevertheless safe there, but by its own
1026
1400
  guards (P1 declines the tight hyphens in `a-b`, the cluster guard declines `2026-08-15`), not
@@ -1040,9 +1414,13 @@ means the claim holds in those modes only.
1040
1414
  mark that would have been unbalanced within its own span may now pair across an element, which
1041
1415
  converts more, never less.
1042
1416
  - **`nbsp` §4 "The start or end of a text unit"** — **[P] in all modes, and strengthened.** A
1043
- "text unit" is the concatenation, and §3.4 additionally refuses any insertion at a span
1044
- boundary, so no span ever begins or ends with a character `nbsp` put there. The guarantee is
1045
- now about element boundaries as well as document boundaries.
1417
+ "text unit" is §3.1's — the concatenation — and §3.4 additionally refuses any insertion at a
1418
+ span boundary, so no span ever begins or ends with a character `nbsp` put there. The guarantee
1419
+ is now about element boundaries as well as document boundaries. Since 1.7.0 a document may have
1420
+ two units (§3.7.4), and the guarantee is read **per unit**: the frontmatter block's first and
1421
+ last positions are unit extremities of their own, which refuses more than one unit would, never
1422
+ less. That is also what makes `-separate-unit` deterministic rather than a coincidence — the
1423
+ same reading `quotes` gives a unit edge.
1046
1424
 
1047
1425
  - **`dashes` §4, the `-spaced` forms** — a new **[R]** consequence in `html`/`markdown`, not a
1048
1426
  change to any existing bullet. A `-spaced` locale converts a dash only where the replacement
@@ -1223,6 +1601,47 @@ rule-local.
1223
1601
  document has yet needed it. If one does, this is where it reopens, and the extension is
1224
1602
  additive — a caller passing bare names keeps today's behaviour.
1225
1603
 
1604
+ 13. **What `frontmatterKeys` deliberately does not claim (spec 1.7.0).** Three entries, and the
1605
+ last is a number rather than a construct:
1606
+
1607
+ - **TOML frontmatter (`+++`) yields no spans**, with the option given or not. Its quoting is a
1608
+ second grammar — U+005C escapes in basic strings, none in literal strings, multi-line forms
1609
+ of both — and §3.8.3's posture is to claim only what the scan has proved. Neither report
1610
+ behind the option (polytypo/polytypo#13, #26) asked for TOML, and the 187-file corpus of
1611
+ §3.7.4.1 contains none. Widening to TOML is additive: a caller passing keys today keeps
1612
+ today's behaviour.
1613
+ - **~~The block itself is recognised by the mode, not by a scan specified here.~~ Closed in
1614
+ 1.8.0 by §3.7.3a.** It was here because §3.7.4's content range is exact once there is a
1615
+ block, while the block itself came from each parser's own frontmatter support — and the
1616
+ measurement that followed found those disagreeing two against two on a trailing space,
1617
+ with the two that declined the block typesetting the metadata. The locator is now
1618
+ specified, which is where it belonged: it governs the skip for every caller, not only
1619
+ those who pass the option.
1620
+ - **A lone-U+000D document has a block, and `frontmatterKeys` yields nothing inside it.**
1621
+ §3.7.3a step 5 finds the block by CommonMark's line model and §3.8.4 reads content by its
1622
+ own, so the block reaches the content scan as a single line. An earlier draft let that
1623
+ stand and called it inert; measuring it showed it was not. On two mapping lines a quotation
1624
+ opened on one paired with a mark on the other, an unlisted line inside the listed key's
1625
+ scalar took `fr`'s spacing, and the U+000D landed inside a span — which §3.8.4 forbids in
1626
+ the same breath. §3.7.4 therefore declines any content line carrying such a U+000D — per
1627
+ line, so that a stray one inside a single quoted value costs that value and not the block.
1628
+ The block is still skipped, so nothing machine-read is typeset either way. A lone-U+000D
1629
+ document is one line to §3.8.4 and therefore loses the whole block, which is the price of
1630
+ not widening that section here.
1631
+ - **§3.8.6's single-quoted bail costs more here than anywhere it has been measured before.**
1632
+ Of the 1858 corpus values a locale would convert across eight locales, **1040 yield no spans
1633
+ when written as a single-quoted scalar** — 130 of 247 in `en-GB` alone, the figure 1.7.0
1634
+ shipped with, and `scripts/miss-census.mjs` in this repository is what reproduces either on
1635
+ demand. An apostrophe inside such a scalar is spelled `''`, and an apostrophe
1636
+ is exactly what `apostrophe` and `quotes` convert, so the bail falls hardest on the values
1637
+ the option exists for. Double-quoted, none bail. The corpus as authored is entirely
1638
+ double-quoted, so its own author never meets this; a caller whose YAML style is single
1639
+ quotes gets nothing on half their prose, and gets it silently. The repair is not this
1640
+ section's to make: §3.8.6 is ratified and measured, and the obvious extension — treating
1641
+ `''` as an opaque two-code-point unit, exactly as §3.8.6 already treats `:` and `#` inside a
1642
+ plain scalar — changes `yaml` mode for every caller and needs its own measurement and its
1643
+ own sign-off.
1644
+
1226
1645
  ## 8. Fixture coverage strategy (non-normative)
1227
1646
 
1228
1647
  This section records why `spec/fixtures/` does not carry the full every-locale × four-modes
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.6.3",
2
+ "spec": "1.8.0",
3
3
  "$comment": "Single source of truth for pipeline order. Rules run in ascending `order`. Disabling a rule removes it from the sequence and never reorders the rest. Rule ids are public API (see docs/ARCHITECTURE.md section 5). \"spec\" here must track spec/VERSION exactly — it is not itself the global version source; scripts/validate-spec.mjs enforces the match.",
4
4
  "rules": [
5
5
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://polytypo.org/schema/fixtures.schema.json",
4
4
  "title": "polytypo conformance fixtures",
5
- "description": "Executable form of the rule semantics. Every case is also an idempotency case: a runner must additionally assert transform(out) == out, passing the case's own options (mode, dialect, keys, rules, narrowNbsp) on that second call exactly as on the first — a case is a fixed point under its own options, not under the defaults.",
5
+ "description": "Executable form of the rule semantics. Every case is also an idempotency case: a runner must additionally assert transform(out) == out, passing the case's own options (mode, dialect, keys, frontmatterKeys, rules, narrowNbsp) on that second call exactly as on the first — a case is a fixed point under its own options, not under the defaults.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["spec", "locale", "cases"],
@@ -29,6 +29,11 @@
29
29
  "if": { "properties": { "mode": { "const": "yaml" } }, "required": ["mode"] },
30
30
  "then": { "required": ["keys"] },
31
31
  "else": { "not": { "required": ["keys"] } }
32
+ },
33
+ {
34
+ "if": { "properties": { "mode": { "const": "markdown" } }, "required": ["mode"] },
35
+ "then": true,
36
+ "else": { "not": { "required": ["frontmatterKeys"] } }
32
37
  }
33
38
  ],
34
39
  "oneOf": [{ "required": ["out"] }, { "required": ["throws"] }],
@@ -62,6 +67,12 @@
62
67
  "items": { "type": "string" },
63
68
  "description": "Required when mode is \"yaml\" — spec/rules/modes.md §3.8.2 gives the option no default, so a fixture must name the processable keys exactly as a caller would. An empty array is legal and processes nothing. Meaningless for the other modes."
64
69
  },
70
+ "frontmatterKeys": {
71
+ "type": "array",
72
+ "items": { "type": "string" },
73
+ "description": "Optional, spec 1.7.0, and only meaningful when mode is \"markdown\" — spec/rules/modes.md §3.7.4's opt-out from the frontmatter skip. Absent means the block is skipped whole, exactly as before 1.7.0; an empty array is legal and yields no spans. A runner passes it through to transform() as it passes dialect.",
74
+ "$comment": "The block is its own text unit (§3.7.4), so this option cannot change a byte of the body — every case released before 1.7.0 keeps its recorded output whatever it is set to."
75
+ },
65
76
  "in": { "type": "string" },
66
77
  "out": { "type": "string" },
67
78
  "throws": {
@@ -59,6 +59,35 @@ module Polytypo
59
59
  value.to_set
60
60
  end
61
61
 
62
+ # modes.md 3.7.4: "markdown" mode's `frontmatter_keys` option (spec 1.7.0).
63
+ #
64
+ # Optional, unlike `keys` -- nil means the frontmatter block is skipped whole, which is every
65
+ # pre-1.7.0 document's behaviour -- and an empty Array is legal, exactly as it is for `keys`.
66
+ # Checked after dialect and before the parse, which is what decides that a document failing to
67
+ # parse in its dialect still reports the option error rather than CODE_MALFORMED_INPUT.
68
+ def self.resolve_frontmatter_keys(value)
69
+ return nil if value.nil?
70
+
71
+ unless value.is_a?(Array)
72
+ raise Polytypo::Error.new(
73
+ Polytypo::CODE_INVALID_OPTION,
74
+ '"frontmatter_keys" must be an Array of Strings when given (modes.md 3.7.4). ' \
75
+ "Received #{value.inspect}.",
76
+ )
77
+ end
78
+
79
+ value.each do |key|
80
+ next if key.is_a?(String)
81
+
82
+ raise Polytypo::Error.new(
83
+ Polytypo::CODE_INVALID_OPTION,
84
+ "\"frontmatter_keys\" must contain only Strings; received #{key.inspect}.",
85
+ )
86
+ end
87
+
88
+ value.to_set
89
+ end
90
+
62
91
  # Resolves the locale, builds the rule plan, and runs each enabled rule in
63
92
  # spec/rules/order.json order over a code-point array, applying its edits before the next
64
93
  # rule sees it. No module-level mutable state beyond the immutable, load-once-on-first-use