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.
- checksums.yaml +4 -4
- data/README.md +25 -0
- data/lib/polytypo/data/.not-vendored +11 -0
- data/lib/polytypo/data/README.md +16 -11
- data/lib/polytypo/data/VERSION +1 -1
- data/lib/polytypo/data/fixtures/cs.json +1 -1
- data/lib/polytypo/data/fixtures/de-CH.json +1 -1
- data/lib/polytypo/data/fixtures/de-DE.json +14 -1
- data/lib/polytypo/data/fixtures/el.json +1 -1
- data/lib/polytypo/data/fixtures/en-GB.json +1 -1
- data/lib/polytypo/data/fixtures/en-US.json +521 -1
- data/lib/polytypo/data/fixtures/es.json +1 -1
- data/lib/polytypo/data/fixtures/fi.json +1 -1
- data/lib/polytypo/data/fixtures/fr-CA.json +1 -1
- data/lib/polytypo/data/fixtures/fr.json +68 -1
- data/lib/polytypo/data/fixtures/it.json +1 -1
- data/lib/polytypo/data/fixtures/locale-resolution.json +1 -1
- data/lib/polytypo/data/fixtures/nl.json +1 -1
- data/lib/polytypo/data/fixtures/pl.json +1 -1
- data/lib/polytypo/data/fixtures/pt-BR.json +1 -1
- data/lib/polytypo/data/fixtures/pt-PT.json +1 -1
- data/lib/polytypo/data/fixtures/ru.json +1 -1
- data/lib/polytypo/data/fixtures/sv.json +1 -1
- data/lib/polytypo/data/fixtures/tr.json +1 -1
- data/lib/polytypo/data/fixtures/uk.json +1 -1
- data/lib/polytypo/data/locales/cs.json +56 -37
- data/lib/polytypo/data/locales/de-CH.json +43 -34
- data/lib/polytypo/data/locales/de-DE.json +47 -32
- data/lib/polytypo/data/locales/el.json +51 -1
- data/lib/polytypo/data/locales/en-GB.json +56 -4
- data/lib/polytypo/data/locales/en-US.json +68 -4
- data/lib/polytypo/data/locales/es.json +66 -29
- data/lib/polytypo/data/locales/fi.json +77 -7
- data/lib/polytypo/data/locales/fr-CA.json +63 -36
- data/lib/polytypo/data/locales/fr.json +70 -41
- data/lib/polytypo/data/locales/it.json +65 -22
- data/lib/polytypo/data/locales/nl.json +61 -29
- data/lib/polytypo/data/locales/pl.json +61 -28
- data/lib/polytypo/data/locales/pt-BR.json +53 -28
- data/lib/polytypo/data/locales/pt-PT.json +55 -28
- data/lib/polytypo/data/locales/registry.json +1 -1
- data/lib/polytypo/data/locales/ru.json +45 -30
- data/lib/polytypo/data/locales/sv.json +63 -4
- data/lib/polytypo/data/locales/tr.json +72 -32
- data/lib/polytypo/data/locales/uk.json +55 -27
- data/lib/polytypo/data/rules/modes.md +430 -11
- data/lib/polytypo/data/rules/order.json +1 -1
- data/lib/polytypo/data/schema/fixtures.schema.json +12 -1
- data/lib/polytypo/engine/pipeline.rb +29 -0
- data/lib/polytypo/modes/markdown.rb +256 -31
- data/lib/polytypo/modes/runner.rb +33 -3
- data/lib/polytypo/version.rb +1 -1
- data/lib/polytypo.rb +27 -12
- 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.
|
|
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 —
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
1044
|
-
boundary, so no span ever begins or ends with a character `nbsp` put there. The guarantee
|
|
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.
|
|
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
|