polytypo 1.17.2 → 1.17.4

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/polytypo/data/VERSION +1 -1
  3. data/lib/polytypo/data/fixtures/bg.json +1 -1
  4. data/lib/polytypo/data/fixtures/cs.json +1 -1
  5. data/lib/polytypo/data/fixtures/de-CH.json +1 -1
  6. data/lib/polytypo/data/fixtures/de-DE.json +9 -26
  7. data/lib/polytypo/data/fixtures/el.json +1 -1
  8. data/lib/polytypo/data/fixtures/en-GB.json +225 -1
  9. data/lib/polytypo/data/fixtures/en-US.json +172 -516
  10. data/lib/polytypo/data/fixtures/es.json +1 -1
  11. data/lib/polytypo/data/fixtures/fi.json +1 -1
  12. data/lib/polytypo/data/fixtures/fr-CA.json +1 -1
  13. data/lib/polytypo/data/fixtures/fr.json +12 -37
  14. data/lib/polytypo/data/fixtures/hr.json +1 -1
  15. data/lib/polytypo/data/fixtures/hu.json +1 -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/lt.json +1 -1
  19. data/lib/polytypo/data/fixtures/nl.json +1 -1
  20. data/lib/polytypo/data/fixtures/pl.json +1 -1
  21. data/lib/polytypo/data/fixtures/pt-BR.json +1 -1
  22. data/lib/polytypo/data/fixtures/pt-PT.json +1 -1
  23. data/lib/polytypo/data/fixtures/ro.json +1 -1
  24. data/lib/polytypo/data/fixtures/ru.json +1 -1
  25. data/lib/polytypo/data/fixtures/sk.json +1 -1
  26. data/lib/polytypo/data/fixtures/sl.json +1 -1
  27. data/lib/polytypo/data/fixtures/sr.json +1 -1
  28. data/lib/polytypo/data/fixtures/sv.json +1 -1
  29. data/lib/polytypo/data/fixtures/tr.json +1 -1
  30. data/lib/polytypo/data/fixtures/uk.json +1 -1
  31. data/lib/polytypo/data/locales/registry.json +1 -1
  32. data/lib/polytypo/data/rules/modes.md +71 -6
  33. data/lib/polytypo/data/rules/order.json +1 -1
  34. data/lib/polytypo/modes/html.rb +0 -1
  35. data/lib/polytypo/modes/markdown.rb +67 -12
  36. data/lib/polytypo/modes/runner.rb +2 -3
  37. data/lib/polytypo/modes/spans.rb +59 -5
  38. data/lib/polytypo/version.rb +1 -1
  39. metadata +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "es",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "fi",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "fr-CA",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "fr",
4
4
  "cases": [
5
5
  {
@@ -752,9 +752,7 @@
752
752
  "rule": "nbsp",
753
753
  "mode": "markdown",
754
754
  "dialect": "commonmark",
755
- "frontmatterKeys": [
756
- "title"
757
- ],
755
+ "frontmatterKeys": ["title"],
758
756
  "in": "---\ntitle: \"Chapitre 1: le début\"\ndate: \"2026-09-25\"\n---\n\nVoici: le corps.\n",
759
757
  "out": "---\ntitle: \"Chapitre 1 : le début\"\ndate: \"2026-09-25\"\n---\n\nVoici : le corps.\n",
760
758
  "note": "modes.md §3.7.4 with the locale that made §3.7.3 skip the block in the first place: inside a listed quoted scalar a colon is content, so fr inserts its narrow no-break space, while the unlisted date field and the key itself keep every byte. That contrast is the whole argument for a caller-named list."
@@ -863,9 +861,7 @@
863
861
  "id": "fr-yaml-narrow-space-in-quoted",
864
862
  "rule": "nbsp",
865
863
  "mode": "yaml",
866
- "keys": [
867
- "description"
868
- ],
864
+ "keys": ["description"],
869
865
  "in": "description: \"Une note !\"\n",
870
866
  "out": "description: \"Une note !\"\n",
871
867
  "note": "modes.md §3.8.6: a quoted scalar's content is one span, and French spacing applies inside it exactly as in text mode."
@@ -874,9 +870,7 @@
874
870
  "id": "fr-yaml-no-space-across-colon-split",
875
871
  "rule": "nbsp",
876
872
  "mode": "yaml",
877
- "keys": [
878
- "description"
879
- ],
873
+ "keys": ["description"],
880
874
  "in": "description: prose:more prose\n",
881
875
  "out": "description: prose:more prose\n",
882
876
  "note": "modes.md §3.8.6: the `:` is outside every span, so the insertion `nbsp` would make before it lands on a span edge and is discarded by §3.4. This is the accepted cost of the split, and it is what keeps the scalar parseable."
@@ -885,9 +879,7 @@
885
879
  "id": "fr-yaml-guillemets-across-block-lines",
886
880
  "rule": "quotes",
887
881
  "mode": "yaml",
888
- "keys": [
889
- "description"
890
- ],
882
+ "keys": ["description"],
891
883
  "in": "description: |\n Il a dit \"bonjour\n monsieur\" et il est parti\n",
892
884
  "out": "description: |\n Il a dit « bonjour\n monsieur » et il est parti\n",
893
885
  "note": "modes.md §3.2 model C in a locale whose quotation output is guillemets plus U+00A0 rather than curly quotes — structurally different from the en-US case of the same shape."
@@ -953,9 +945,7 @@
953
945
  "id": "fr-yaml-plain-elision",
954
946
  "rule": "apostrophe",
955
947
  "mode": "yaml",
956
- "keys": [
957
- "description"
958
- ],
948
+ "keys": ["description"],
959
949
  "in": "description: l'idee et l'autre\n",
960
950
  "out": "description: l’idee et l’autre\n",
961
951
  "note": "modes.md §3.8.6 in a locale where the apostrophe is elision rather than possession: the same cell as en-us-yaml-plain-apostrophe, and §8 item 4's requirement that a construct be pinned in more than one locale rather than the same English output with the locale field changed."
@@ -964,9 +954,7 @@
964
954
  "id": "fr-yaml-single-quoted-escape-elision-stays",
965
955
  "rule": "apostrophe",
966
956
  "mode": "yaml",
967
- "keys": [
968
- "description"
969
- ],
957
+ "keys": ["description"],
970
958
  "in": "description: 'l''idee et l''autre'\n",
971
959
  "out": "description: 'l''idee et l''autre'\n",
972
960
  "note": "modes.md §3.8.6: in a locale where the apostrophe is elision, the unit is the elision, and it is outside every span. The scalar has nothing else to convert and comes back unchanged — the cost §7.13 measures, pinned so that it is a documented property and not a surprise."
@@ -975,9 +963,7 @@
975
963
  "id": "fr-yaml-single-quoted-escape-split",
976
964
  "rule": "dashes",
977
965
  "mode": "yaml",
978
- "keys": [
979
- "description"
980
- ],
966
+ "keys": ["description"],
981
967
  "in": "description: 'l''idee -- et ''l''autre...'\n",
982
968
  "out": "description: 'l''idee — et ''l''autre…'\n",
983
969
  "note": "modes.md §3.8.6: a spaced dash and an ellipsis interior to a span convert, either side of units; the elisions stay straight."
@@ -986,9 +972,7 @@
986
972
  "id": "fr-yaml-single-quoted-escape-growth-declined",
987
973
  "rule": "dashes",
988
974
  "mode": "yaml",
989
- "keys": [
990
- "description"
991
- ],
975
+ "keys": ["description"],
992
976
  "in": "description: 'a''--''b'\n",
993
977
  "out": "description: 'a''--''b'\n",
994
978
  "note": "modes.md §3.8.6 and §3.4: `fr` is a `-spaced` locale, so the dash would grow from 2 to 3 code points against a unit. The edge-growth rule discards it, as it does against `:` and `#`. The en-US case with the same input is applied, which is what makes this one discriminate."
@@ -997,9 +981,7 @@
997
981
  "id": "fr-yaml-single-quoted-no-escape",
998
982
  "rule": "dashes",
999
983
  "mode": "yaml",
1000
- "keys": [
1001
- "description"
1002
- ],
984
+ "keys": ["description"],
1003
985
  "in": "description: 'deux -- trois'\n",
1004
986
  "out": "description: 'deux — trois'\n",
1005
987
  "note": "modes.md §3.8.6: a single-quoted scalar with nothing doubled inside it is claimed like any other. The style itself never disables the mode — only the doubling does, which is what the neighbouring case pins."
@@ -1018,10 +1000,7 @@
1018
1000
  "rule": "quotes",
1019
1001
  "mode": "markdown",
1020
1002
  "dialect": "commonmark",
1021
- "frontmatterKeys": [
1022
- "title",
1023
- "slug"
1024
- ],
1003
+ "frontmatterKeys": ["title", "slug"],
1025
1004
  "in": "---\ntitle: \"a\rb\"\nslug: c \"d\"\n---\n\nBody \"q\" here.\n",
1026
1005
  "out": "---\ntitle: \"a\rb\"\nslug: c « d »\n---\n\nBody « q » here.\n",
1027
1006
  "note": "modes.md §3.7.4 per-line U+000D bail in a locale whose quotation marks carry their own spacing, so the declined line and the converted one are visibly different constructions rather than the same English output relabelled (§8 item 4)."
@@ -1030,11 +1009,7 @@
1030
1009
  "id": "fr-yaml-lone-cr-costs-one-line",
1031
1010
  "rule": "quotes",
1032
1011
  "mode": "yaml",
1033
- "keys": [
1034
- "title",
1035
- "slug",
1036
- "summary"
1037
- ],
1012
+ "keys": ["title", "slug", "summary"],
1038
1013
  "in": "title: a \"b\"\nslug: c\rd \"e\"\n",
1039
1014
  "out": "title: a « b »\nslug: c\rd \"e\"\n",
1040
1015
  "note": "modes.md §3.8.4 step 9 in a locale whose quotation marks carry their own spacing: the line with the lone U+000D is declined and the line before it converts, so the cost is one line and not the document."
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "hr",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "hu",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "it",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "cases": [
4
4
  {
5
5
  "id": "exact-en-us",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "lt",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "nl",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "pl",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "pt-BR",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "pt-PT",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "ro",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "ru",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "sk",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "sl",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "sr",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "sv",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "tr",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "locale": "uk",
4
4
  "cases": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
3
3
  "$comment": "Locale resolution input. Algorithm is specified in spec/rules/locale-resolution.md and is identical in every runtime — never delegate it to a platform locale-negotiation library. \"spec\" here must track spec/VERSION exactly — it is not itself the global version source; scripts/validate-spec.mjs enforces the match.",
4
4
  "locales": [
5
5
  "en-US",
@@ -19,7 +19,7 @@ step 9 with §3.8.5's content-line condition, added in 1.10.0, §3.8.4's carriag
19
19
  continuation line with the bails of §3.8.6 that use it, added in 1.11.0, and §3.8.4's node lines,
20
20
  declined keys, node markers and own-line region, with the changes to steps 1, 3, 4, 5, 6, 7 and 8
21
21
  that go with them, added in 1.11.0, and §3.9 with the amendments it carries to §3.1, §3.5 step 2,
22
- §4, §5, §7 item 7 and §8, added in 1.13.0, and §3.4's delimiter-neighbour rule with §3.7.3's autolink-literal tail, terminator guard and reference labels, and the amendments they carry to §3.4's deletion paragraph, §5 items 2 and 3, its fifth-part note and testing obligation, and §6, added in 1.17.2).
22
+ §4, §5, §7 item 7 and §8, added in 1.13.0, and §3.4's delimiter-neighbour rule with §3.7.3's autolink-literal tail, terminator guard and reference labels, and the amendments they carry to §3.4's deletion paragraph, §5 items 2 and 3, its fifth-part note and testing obligation, and §6, added in 1.17.2, and §3.4's escape-and-reference rule with the amendments it carries to §3.6, §3.7.3, §5 item 2's fifth-part note, §5 item 3 and the testing obligation, added in 1.17.4).
23
23
 
24
24
  ---
25
25
 
@@ -412,6 +412,54 @@ the rule tests whitespace alone. A parser that implements an older definition (p
412
412
  would classify them differently, and two fixtures (`en-us-markdown-commonmark-delimiter-neighbour-symbols-kept`)
413
413
  make such a port fail rather than drift. No rule emits a member of `DELIM`.
414
414
 
415
+ **The escape-and-reference rule (`markdown` and `html`, spec 1.17.4).** The delimiter-neighbour rule
416
+ guards a change in how a parser _classifies_ a token. A second class changes the _text_ a parser
417
+ produces while the parse tree keeps its shape, which a comparison of shapes cannot see. A U+005C
418
+ that stands in a span as literal text has no escape in it, because the character after it is a
419
+ space; a `&` followed by letters and a space is no character reference (§3.6 keeps a `&` that
420
+ begins no well-formed reference inside its span). Deleting the space joins the two to what comes
421
+ next:
422
+
423
+ | Mode | Input | Locale | Output before 1.17.4 | What changed |
424
+ | ---------- | ------------ | ------- | -------------------- | ----------------------------------------------------------------- |
425
+ | `markdown` | `foo\ , bar` | `en-GB` | `foo\, bar` | the backslash now escapes the comma; it renders as `foo, bar` |
426
+ | `markdown` | `AT&amp ;T` | `en-GB` | `AT&T` | a character reference now exists; it renders as `AT&T` |
427
+ | `html` | `<p>a &hellip ;b</p>` | `en-GB` | `<p>a &hellip;b</p>` | a character reference now exists; it renders as `a …b` |
428
+
429
+ A backslash escape or a well-formed reference that the parser already read lies outside every span
430
+ (§3.6, §3.7.3), so only a **literal** U+005C or `&` inside a span can be affected, and no parity
431
+ of backslashes needs counting. The rule applies to every span of a document, those that come from
432
+ inline HTML or an HTML block in a Markdown document included. With `ASCIIPUNCT` = U+0021–U+002F, U+003A–U+0040, U+005B–U+0060 and
433
+ U+007B–U+007E, and `p`, `q`, `w`, `r`, `old` and `new` as in the delimiter-neighbour rule:
434
+
435
+ > **The edit is discarded if either of these holds, evaluated on the array the rule read. Item 1
436
+ > applies to `markdown` only, since a backslash is literal in HTML; item 2 applies to `markdown`
437
+ > and `html`.**
438
+ >
439
+ > 1. `cp[p−1]` is U+005C, `old = cp[p]`, `new = w[0]` when `r > 0` and otherwise `cp[q+1]`, and
440
+ > exactly one of `old` and `new` is in `ASCIIPUNCT`.
441
+ > 2. The edit is a deletion (`r = 0`) and, with `a = cp[p−1]` and `b = cp[q+1]` made adjacent, the
442
+ > array then holds a **reference shape** that contains both: U+0026, an optional U+0023, one or
443
+ > more ASCII letters or digits, and U+003B (`a` may be the U+0026 and `b` the U+0023). A shape is read as a left-to-right scan over those
444
+ > classes; it asks whether the _name_ is a known one no more than the delimiter-neighbour rule
445
+ > asks whether a flank is a valid one.
446
+
447
+ A marker belongs to no class, a position outside the array is none, and the test reads no other
448
+ code point and no parser state, so it is a function of the array alone and fires identically on
449
+ every run (§5 item 3). It applies to `markdown` and `html` and to no other mode: `text` has neither
450
+ escapes nor references, and `yaml` selects spans by a scan that reads none (§3.8).
451
+
452
+ **Its premise is that no rule emits an ASCII letter, a digit, U+0023, U+0026, U+003B or
453
+ U+005C** (`ellipsis` emits U+002E, which is none of these), so item 2 has nothing to read in a
454
+ replacement and tests deletions only, and, outside raw HTML, a literal U+005C is never followed by ASCII
455
+ punctuation in the input, since that pair would be an escape. Inside raw HTML a backslash is literal
456
+ and item 1 still applies to it: that is **accepted** conservatism (a quotation mark after a backslash
457
+ in an HTML block is left as typed), deterministic and identical in every runtime. `spaces` is the only rule that deletes. The rule is deliberately a test of _shape_, not a
458
+ lookup of the reference names an HTML5 parser knows, because a name table would be a data file
459
+ in five runtimes; it is therefore stricter than a parser: `AT&T ;x` (en-US) keeps its space,
460
+ though `&T;` is no reference. **That is the accepted cost**, together with a literal backslash
461
+ before a space and punctuation, which is rare in prose.
462
+
415
463
  ### 3.5 Applying the result
416
464
 
417
465
  1. Extract the span sequence from the source. Record each span's **source offsets**.
@@ -444,7 +492,8 @@ Skipped, exhaustively and exactly:
444
492
  unit. A text node containing one is split into spans around it, so the reference's spelling is
445
493
  preserved exactly, which is the only way `&nbsp;` does not become a literal U+00A0 and back.
446
494
  **A bare `&` that begins no well-formed reference stays inside its span.** No rule emits or
447
- deletes `&`, so it is in no danger; lifting it out would create a boundary that suppresses
495
+ deletes `&`, so it is in no danger itself (a space deleted beside it can complete a reference,
496
+ which §3.4's escape-and-reference rule, 1.17.4, closes); lifting it out would create a boundary that suppresses
448
497
  conversions on both sides of it for nothing — `Tom & Jerry's "book"` would lose the pairing of
449
498
  its quotation marks. Well-formedness, not the presence of an ampersand, is what makes a
450
499
  skipped region;
@@ -547,6 +596,17 @@ Skipped, exhaustively:
547
596
  - fenced code blocks, including the info string and the fences;
548
597
  - indented code blocks — **`commonmark` only**; MDX has none;
549
598
  - inline code spans, including the backticks;
599
+ - **every backslash escape and every character reference the parser recognises (spec 1.17.4)**, as
600
+ an opaque unit, exactly as `html` treats a well-formed reference (§3.6): the span is split
601
+ around it and the unit lies outside every span. A port that took spans from text-node positions
602
+ would put `\,` and `&amp;` inside a span and disagree with the others about every deletion
603
+ beside them (`x &amp; , y` keeps its space). **"Recognises" is CommonMark 0.31's definition
604
+ (§2.5, with GFM), and it differs from §3.6's:** `&` followed by a name from the HTML5 named
605
+ character reference list, **case-sensitively** (`&AMP;` and `&Abreve;` are references; `&Amp;`
606
+ and `&foo;` are not), or by `#` and 1 to 7 decimal digits, or by `#x` or `#X` and 1 to 6
607
+ hexadecimal digits, then `;`. A value out of Unicode's range (`&#0;`, `&#x110000;`) is still a
608
+ reference. `html` mode tests the _shape_ of a reference (§3.6), not its name, so `&foo;` is
609
+ opaque there and a span's content here;
550
610
  - autolinks `<https://…>` — **`commonmark` only**;
551
611
  - **link and image destinations and titles** — the `(…)` of `[text](url "title")`, and the
552
612
  definition line of a reference link. The **link text** is processable;
@@ -1769,7 +1829,8 @@ they do not carry it over for free either. Write `M` for the whole mode transfor
1769
1829
  same reason. **This list is not claimed to be complete.** A deletion
1770
1830
  can also create a backslash escape or a character reference from literal text (`foo\ ,`
1771
1831
  becomes `foo\,` and `AT&amp ;T` becomes `AT&amp;T`): the parse tree keeps its shape and its
1772
- text changes, which a shape comparison does not see. That class is not closed by 1.17.2.
1832
+ text changes, which a shape comparison does not see. That class was not closed by 1.17.2 and
1833
+ is closed by §3.4's escape-and-reference rule (spec 1.17.4).
1773
1834
  - **Deletion** is only ever of a U+0020, and structural whitespace borders a `BREAK` — a real
1774
1835
  one in `text` mode, a −2 marker in the others — where `spaces` refuses to act
1775
1836
  (`spaces.md` §3.2 step 4). A deletion therefore cannot turn `- item` into `-item`, collapse
@@ -1841,8 +1902,9 @@ they do not carry it over for free either. Write `M` for the whole mode transfor
1841
1902
  that would place code points at a span extremity which were not there before — an insertion at
1842
1903
  a boundary, or a replacement longer than what it replaced — is discarded rather than assigned
1843
1904
  to a side. The discard is a pure function of the array and its span table (since 1.17.2: of
1844
- `(p, q, r, s₀, s₁)`, of the code points beside the edit for §3.4's delimiter-neighbour rule, and
1845
- of whether the span begins at the end of a web address for §3.7.3's terminator guard), so it
1905
+ `(p, q, r, s₀, s₁)`, of the code points beside the edit for §3.4's delimiter-neighbour rule, of
1906
+ the same code points and the run of letters and digits around the join for §3.4's
1907
+ escape-and-reference rule (since 1.17.4), and of whether the span begins at the end of a web address for §3.7.3's terminator guard), so it
1846
1908
  happens identically on every run: a rule that was declined at an edge once is declined there always, and the output
1847
1909
  contains nothing for the next run to reconsider.
1848
1910
 
@@ -1879,7 +1941,10 @@ quotes are curly, because each reaches a different rule. The tree to compare is
1879
1941
  runtime has another parser, that parser's as well), not only the tokenizer's own: the tokenizer reads
1880
1942
  no link in `"www.example.com"`, mdast does, and that is the class §3.7.3's scan closes. Templates
1881
1943
  must include an address with `_`, `*` and `~` inside it after `"`, `'` and `(`, and an address
1882
- directly after a digit. **Two classes are carved out because they are accepted, not because
1944
+ directly after a digit. **Since 1.17.4 the comparison also covers the text values of the tree**, not only its shape: the
1945
+ defect behind the escape-and-reference rule left the shape unchanged. The alphabet must then include
1946
+ U+005C, `&`, `#`, `;`, `,` and a letter, and the templates a literal backslash and a literal
1947
+ `&name` each followed by a space and by each punctuation mark. **Two classes are carved out because they are accepted, not because
1883
1948
  they are untested**: a text that matched no definition in the input and matches one in the output
1884
1949
  (§3.7.3's accepted case), and an e-mail address, whose end only the parser decides.
1885
1950
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "1.17.2",
2
+ "spec": "1.17.4",
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
  {
@@ -91,7 +91,6 @@ module Polytypo
91
91
  end
92
92
  refs
93
93
  end
94
- private_class_method :find_entity_refs
95
94
 
96
95
  # Parses "<name ...>", "</name>" or "<name .../>" (as an array of chars) into
97
96
  # [name, closing, self_closing]. Comments, declarations and processing instructions
@@ -45,7 +45,7 @@ module Polytypo
45
45
  # breaks (no prose content). Frontmatter is NOT in this list and is not a node type here:
46
46
  # comrak's front_matter_delimiter extension is never enabled, because since spec 1.8.0 the
47
47
  # block's extent is modes.md 3.7.3a's scan and not a parser's opinion of it.
48
- SKIP_WHOLE_TYPES = %i[code code_block thematic_break].freeze
48
+ SKIP_WHOLE_TYPES = %i[code code_block thematic_break escaped].freeze
49
49
 
50
50
  # A leaf whose Segment/source_position directly delineates literal, processable prose --
51
51
  # comrak's AST is segment-based like goldmark's: structural bytes (emphasis delimiters,
@@ -287,7 +287,10 @@ module Polytypo
287
287
  parser_source = normalize_lone_cr(mask_frontmatter(source, block))
288
288
 
289
289
  spans = []
290
- state = { stack: [], frontmatter_end: block.nil? ? 0 : block.end, labels: [], url_ends: Set.new }
290
+ state = {
291
+ stack: [], frontmatter_end: block.nil? ? 0 : block.end, labels: [], references: [],
292
+ url_ends: Set.new
293
+ }
291
294
  ParseError.wrap do
292
295
  doc = Commonmarker.parse(
293
296
  parser_source,
@@ -295,7 +298,8 @@ module Polytypo
295
298
  )
296
299
  walk(doc, chars, line_starts, spans, state)
297
300
  end
298
- trim_url_tails(chars, Spans.normalize_spans(remove_ranges(spans, state[:labels])), state[:url_ends])
301
+ kept = remove_ranges(spans, state[:labels] + state[:references])
302
+ trim_url_tails(chars, Spans.normalize_spans(kept), state[:url_ends])
299
303
  end
300
304
 
301
305
  URL_PREFIXES = ["www.", "http://", "https://"].freeze
@@ -352,21 +356,41 @@ module Polytypo
352
356
  def self.remove_ranges(spans, ranges)
353
357
  return spans if ranges.empty?
354
358
 
359
+ merged = merge_ranges(ranges)
360
+ starts = merged.map(&:first)
355
361
  spans.flat_map do |span|
356
- ranges.reduce([span]) do |pieces, r|
357
- pieces.flat_map do |q|
358
- next [q] if r.end <= q.start || r.start >= q.end
359
-
360
- res = []
361
- res << Spans::Span.new(q.start, r.start) if q.start < r.start
362
- res << Spans::Span.new(r.end, q.end) if r.end < q.end
363
- res
362
+ # The first merged range that ends after the span starts is the one before the first
363
+ # start beyond it, or that one's successor; later ranges are visited in order.
364
+ index = [(starts.bsearch_index { |s| s > span.start } || starts.length) - 1, 0].max
365
+ cursor = span.start
366
+ pieces = []
367
+ while index < merged.length && merged[index].first < span.end
368
+ from, to = merged[index]
369
+ if to > cursor
370
+ pieces << Spans::Span.new(cursor, from) if from > cursor
371
+ cursor = to
364
372
  end
373
+ index += 1
365
374
  end
375
+ next [span] if cursor == span.start && pieces.empty?
376
+
377
+ pieces << Spans::Span.new(cursor, span.end) if cursor < span.end
378
+ pieces
366
379
  end
367
380
  end
368
381
  private_class_method :remove_ranges
369
382
 
383
+ def self.merge_ranges(ranges)
384
+ ranges.map { |r| [r.start, r.end] }.sort.each_with_object([]) do |(from, to), out|
385
+ if out.any? && from <= out[-1][1]
386
+ out[-1][1] = to if to > out[-1][1]
387
+ else
388
+ out << [from, to]
389
+ end
390
+ end
391
+ end
392
+ private_class_method :merge_ranges
393
+
370
394
  # modes.md 3.7.3 (spec 1.17.2): the text of a shortcut or collapsed reference is its label.
371
395
  # A node exists only when the parser formed it from a definition, so the kind is read off the
372
396
  # source after the closing bracket of the text: nothing (shortcut), "[]" (collapsed); a
@@ -426,6 +450,34 @@ module Polytypo
426
450
  end
427
451
  private_class_method :emit_span
428
452
 
453
+ # modes.md 3.7.3 (spec 1.17.4): a character reference the parser recognises is an opaque unit
454
+ # outside every span, as in html (3.6). comrak folds a reference into its text node, so the
455
+ # candidates are found by shape in the node's source and each is kept only when the parser
456
+ # does recognise it: a numeric one within CommonMark's digit limits, a named one when parsing
457
+ # it alone yields something other than its own spelling.
458
+ def self.note_references(chars, start_offset, end_offset, state)
459
+ slice = chars[start_offset...end_offset]
460
+ Html.find_entity_refs(slice).each do |from, to|
461
+ text = slice[from...to].join
462
+ next unless recognised_reference?(text)
463
+
464
+ state[:references] << Spans::Span.new(start_offset + from, start_offset + to)
465
+ end
466
+ end
467
+ private_class_method :note_references
468
+
469
+ def self.recognised_reference?(text)
470
+ case text
471
+ when /\A&#[xX](\h{1,6});\z/o, /\A&#(\d{1,7});\z/o
472
+ true
473
+ when /\A&#/
474
+ false
475
+ else
476
+ Commonmarker.parse(text).first_child.first_child.string_content != text
477
+ end
478
+ end
479
+ private_class_method :recognised_reference?
480
+
429
481
  def self.walk(node, chars, line_starts, spans, state)
430
482
  type = node.type
431
483
  return if SKIP_WHOLE_TYPES.include?(type)
@@ -466,7 +518,10 @@ module Polytypo
466
518
  sp = node.source_position
467
519
  start_offset = offset_of(line_starts, sp[:start_line], sp[:start_column])
468
520
  end_offset = offset_of(line_starts, sp[:end_line], sp[:end_column]) + 1
469
- emit_span(spans, start_offset, end_offset, state) if state[:stack].empty?
521
+ if state[:stack].empty?
522
+ emit_span(spans, start_offset, end_offset, state)
523
+ note_references(chars, start_offset, end_offset, state)
524
+ end
470
525
  return
471
526
  end
472
527
 
@@ -15,13 +15,12 @@ module Polytypo
15
15
  # modes.md 3.4 interposed.
16
16
  def self.run_rules_over_spans(cp, plan, locale_data, ctx, guarded = [])
17
17
  current = cp
18
- markdown = ctx.mode == "markdown"
19
18
  plan.each do |rule_id|
20
19
  fn = Engine::Registry.rule(rule_id)
21
20
  edits = fn.call(current, locale_data, ctx)
22
21
  filtered = Spans.filter_boundary_edits(
23
22
  current, edits, Spans.span_ranges_of(current),
24
- delimiter_neighbours: markdown, guarded: guarded
23
+ mode: ctx.mode, guarded: guarded
25
24
  )
26
25
  next if filtered.empty?
27
26
 
@@ -104,7 +103,7 @@ module Polytypo
104
103
  source_cp.length
105
104
  ) do |current, edits|
106
105
  Spans.filter_boundary_edits(current, edits, Spans.span_ranges_of(current),
107
- delimiter_neighbours: ctx.mode == "markdown", guarded: guarded)
106
+ mode: ctx.mode, guarded: guarded)
108
107
  end
109
108
  end
110
109
  end