polytypo 1.0.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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +109 -0
  4. data/lib/polytypo/data/README.md +20 -0
  5. data/lib/polytypo/data/UNICODE +1 -0
  6. data/lib/polytypo/data/VERSION +1 -0
  7. data/lib/polytypo/data/fixtures/de-CH.json +501 -0
  8. data/lib/polytypo/data/fixtures/de-DE.json +547 -0
  9. data/lib/polytypo/data/fixtures/el.json +239 -0
  10. data/lib/polytypo/data/fixtures/en-GB.json +1274 -0
  11. data/lib/polytypo/data/fixtures/en-US.json +1807 -0
  12. data/lib/polytypo/data/fixtures/fi.json +1306 -0
  13. data/lib/polytypo/data/fixtures/fr-CA.json +268 -0
  14. data/lib/polytypo/data/fixtures/fr.json +603 -0
  15. data/lib/polytypo/data/fixtures/locale-resolution.json +209 -0
  16. data/lib/polytypo/data/fixtures/ru.json +688 -0
  17. data/lib/polytypo/data/fixtures/sv.json +1290 -0
  18. data/lib/polytypo/data/locales/de-CH.json +77 -0
  19. data/lib/polytypo/data/locales/de-DE.json +76 -0
  20. data/lib/polytypo/data/locales/el.json +90 -0
  21. data/lib/polytypo/data/locales/en-GB.json +115 -0
  22. data/lib/polytypo/data/locales/en-US.json +133 -0
  23. data/lib/polytypo/data/locales/fi.json +136 -0
  24. data/lib/polytypo/data/locales/fr-CA.json +78 -0
  25. data/lib/polytypo/data/locales/fr.json +84 -0
  26. data/lib/polytypo/data/locales/registry.json +9 -0
  27. data/lib/polytypo/data/locales/ru.json +112 -0
  28. data/lib/polytypo/data/locales/sv.json +124 -0
  29. data/lib/polytypo/data/rules/dashes.md +1238 -0
  30. data/lib/polytypo/data/rules/order.json +78 -0
  31. data/lib/polytypo/data/schema/fixtures.schema.json +79 -0
  32. data/lib/polytypo/data/schema/locale.schema.json +235 -0
  33. data/lib/polytypo/data/schema/registry.schema.json +29 -0
  34. data/lib/polytypo/data/schema/resolution.schema.json +50 -0
  35. data/lib/polytypo/engine/codepoints.rb +24 -0
  36. data/lib/polytypo/engine/edits.rb +64 -0
  37. data/lib/polytypo/engine/locale.rb +138 -0
  38. data/lib/polytypo/engine/pipeline.rb +61 -0
  39. data/lib/polytypo/engine/registry.rb +47 -0
  40. data/lib/polytypo/engine/rules/apostrophe.rb +127 -0
  41. data/lib/polytypo/engine/rules/dash_shared.rb +342 -0
  42. data/lib/polytypo/engine/rules/dashes.rb +125 -0
  43. data/lib/polytypo/engine/rules/ellipsis.rb +100 -0
  44. data/lib/polytypo/engine/rules/hyphen.rb +207 -0
  45. data/lib/polytypo/engine/rules/nbsp.rb +616 -0
  46. data/lib/polytypo/engine/rules/quote_ambiguity.rb +241 -0
  47. data/lib/polytypo/engine/rules/quotes.rb +420 -0
  48. data/lib/polytypo/engine/rules/ranges.rb +124 -0
  49. data/lib/polytypo/engine/rules/spaces.rb +232 -0
  50. data/lib/polytypo/engine/rules/symbols.rb +291 -0
  51. data/lib/polytypo/engine/rules.rb +19 -0
  52. data/lib/polytypo/engine/sentinels.rb +23 -0
  53. data/lib/polytypo/engine/unicode_util.rb +390 -0
  54. data/lib/polytypo/errors.rb +24 -0
  55. data/lib/polytypo/modes/html.rb +233 -0
  56. data/lib/polytypo/modes/markdown.rb +187 -0
  57. data/lib/polytypo/modes/parse_error.rb +19 -0
  58. data/lib/polytypo/modes/runner.rb +57 -0
  59. data/lib/polytypo/modes/spans.rb +132 -0
  60. data/lib/polytypo/version.rb +5 -0
  61. data/lib/polytypo.rb +91 -0
  62. data/polytypo.gemspec +37 -0
  63. metadata +122 -0
@@ -0,0 +1,603 @@
1
+ {
2
+ "spec": "1.0.0",
3
+ "locale": "fr",
4
+ "cases": [
5
+ {
6
+ "id": "fr-spaces-collapse-run",
7
+ "rule": "spaces",
8
+ "mode": "text",
9
+ "in": "Bonjour le monde.",
10
+ "out": "Bonjour le monde.",
11
+ "note": "spaces.md §6 case 1: a run of ordinary spaces bounded by CONTENT collapses to one."
12
+ },
13
+ {
14
+ "id": "fr-spaces-before-punctuation",
15
+ "rule": "spaces",
16
+ "mode": "text",
17
+ "in": "Bonjour ! Ça va ?",
18
+ "out": "Bonjour ! Ça va ?",
19
+ "note": "spaces.md §6 case 9: the ordinary spaces before ! and ? are deleted and nbsp (order 70) re-inserts U+202F, so the output is deterministic however the author typed it."
20
+ },
21
+ {
22
+ "id": "fr-spaces-brackets",
23
+ "rule": "spaces",
24
+ "mode": "text",
25
+ "in": "a ( b , c ) d",
26
+ "out": "a (b, c) d",
27
+ "note": "spaces.md §3.4 worked trace."
28
+ },
29
+ {
30
+ "id": "fr-spaces-empty-bracket-guard",
31
+ "rule": "spaces",
32
+ "mode": "text",
33
+ "in": "- [ ] acheter du lait",
34
+ "out": "- [ ] acheter du lait",
35
+ "note": "spaces.md §3.3: the GFM task-list marker survives. The leading hyphen is asymmetric and dashes declines it."
36
+ },
37
+ {
38
+ "id": "fr-spaces-nbsp-is-content",
39
+ "rule": "spaces",
40
+ "mode": "text",
41
+ "in": "5  km",
42
+ "out": "5  km",
43
+ "note": "spaces.md §6 case 7: U+00A0 is CONTENT and is never collapsed; only the U+0020 run beside it. nbsp N5 does not bind here because cp[a-2] is the U+00A0, not a digit."
44
+ },
45
+ {
46
+ "id": "fr-spaces-leading-indentation",
47
+ "rule": "spaces",
48
+ "mode": "text",
49
+ "in": " code indenté",
50
+ "out": " code indenté",
51
+ "note": "spaces.md §4: a run at index 0 is never touched."
52
+ },
53
+ {
54
+ "id": "fr-ellipsis-three-dots",
55
+ "rule": "ellipsis",
56
+ "mode": "text",
57
+ "in": "Attendez... quoi ?",
58
+ "out": "Attendez… quoi ?",
59
+ "note": "ellipsis.md §6 case 1."
60
+ },
61
+ {
62
+ "id": "fr-ellipsis-mixed-run",
63
+ "rule": "ellipsis",
64
+ "mode": "text",
65
+ "in": "Attendez…… quoi",
66
+ "out": "Attendez… quoi",
67
+ "note": "ellipsis.md §6 case 2: a mixed or repeated run normalises to one U+2026."
68
+ },
69
+ {
70
+ "id": "fr-ellipsis-two-dots-after-terminal",
71
+ "rule": "ellipsis",
72
+ "mode": "text",
73
+ "in": "Vraiment?..",
74
+ "out": "Vraiment ?…",
75
+ "note": "Two rules in sequence. ellipsis.md §6 case 3: fr has abbreviatedAfterTerminal = false, so `?..` is a typing slip for `?…`. Then nbsp N2 still inserts U+202F before the question mark: U+2026 is in the accepted set of N1/N2's right-context guard (nbsp.md §3.3 step 2), because that guard exists to spot a punctuation mark INSIDE a token — `http://`, `12:30` — and an ellipsis after a question mark is nothing of the kind. A port whose right-context guard lists only SPACELIKE and CLOSEISH gives `Vraiment?…` no fine space while `Vraiment ?` gets one."
76
+ },
77
+ {
78
+ "id": "fr-ellipsis-two-dot-run-inert",
79
+ "rule": "ellipsis",
80
+ "mode": "text",
81
+ "in": "Version 1..5 et ../docs",
82
+ "out": "Version 1..5 et ../docs",
83
+ "note": "ellipsis.md §6 cases 4 and 5: a two-dot run is inert in a locale that does not abbreviate. The space before `../docs` survives too, by the lone-dot condition (spaces.md §3.4): a U+0020 may be stripped before U+002E only when the maximal dot run at that index has length exactly 1. A port that strips before any member of STRIP-BEFORE produces `et../docs` and silently destroys word spacing in a relative path."
84
+ },
85
+ {
86
+ "id": "fr-ellipsis-existing",
87
+ "rule": "ellipsis",
88
+ "mode": "text",
89
+ "in": "Il est parti…",
90
+ "out": "Il est parti…",
91
+ "note": "ellipsis.md §6 case 6: already a lone U+2026."
92
+ },
93
+ {
94
+ "id": "fr-dashes-parenthetical-tight-input",
95
+ "rule": "dashes",
96
+ "mode": "text",
97
+ "in": "Le plan--s'il existe--échoue.",
98
+ "out": "Le plan — s’il existe — échoue.",
99
+ "note": "dash.parenthetical = em-spaced (Jacques André §2.2, tableau 1): U+2014 with an ordinary space on each side. The apostrophes are the apostrophe rule's doing."
100
+ },
101
+ {
102
+ "id": "fr-dashes-parenthetical-spaced-input",
103
+ "rule": "dashes",
104
+ "mode": "text",
105
+ "in": "Le plan - il existe - échoue.",
106
+ "out": "Le plan — il existe — échoue.",
107
+ "note": "A spaced bare hyphen satisfies P1 and normalises to the locale form."
108
+ },
109
+ {
110
+ "id": "fr-dashes-authored-en-dash-declined",
111
+ "rule": "dashes",
112
+ "mode": "text",
113
+ "in": "Le plan–peut-être–échoue.",
114
+ "out": "Le plan–peut-être–échoue.",
115
+ "note": "dashes.md §3.4 P5 (spec 0.6.0): a pure single authored U+2013 is declined unconditionally, even though fr's `dash.parenthetical` is `em-spaced` and would otherwise both promote the glyph to U+2014 and add spacing, the exact sibling of `fr-dashes-parenthetical-tight-input` above (which uses a hyphen, unaffected by P5). No existing fr fixture exercised authored-en-dash promotion before this change. Correction pass (spec 0.6.0): re-witnessed to remove the straight apostrophe in the original `s'il` -- that made the whole-output assertion depend on `apostrophe` converting it too, obscuring the P5 claim (`transform(in) === in` is only obvious when nothing else in the sentence is transformable). `peut-être`'s internal hyphen is a compound word (P1, dashes.md §3.4), untouched by any rule, so this input is a true rule-isolated fixed point end to end."
116
+ },
117
+ {
118
+ "id": "fr-dashes-parenthetical-roundtrip",
119
+ "rule": "dashes",
120
+ "mode": "text",
121
+ "in": "Le plan — il existe — échoue.",
122
+ "out": "Le plan — il existe — échoue.",
123
+ "note": "Already the target form."
124
+ },
125
+ {
126
+ "id": "fr-ranges-none-years",
127
+ "rule": "ranges",
128
+ "mode": "text",
129
+ "in": "Les années 1914-1918 furent longues.",
130
+ "out": "Les années 1914-1918 furent longues.",
131
+ "note": "spec 0.5.0 ownership correction: moved from `dashes` to `ranges` (`rules: {ranges: true}` explicit — `ranges` defaults off) — this case tests G1-G5/cluster/binding behaviour that `dashes` no longer evaluates at all for a digit-flanked token; it was vacuous under `rule: \"dashes\"` after the split. dash.range = none for fr: the token IS classified as a range and then nothing at all is emitted (dashes.md §3.3, case 23). Contrary to what most tools do, French page and year intervals keep the typed hyphen-minus — the fr locale file records that neither the Imprimerie nationale's Lexique nor Jacques André prescribes a range dash, and a missing citation is never a licence to guess. ranges.md §2 is the current normative source for the `range: \"none\"` no-op.",
132
+ "rules": {
133
+ "ranges": true
134
+ }
135
+ },
136
+ {
137
+ "id": "fr-ranges-none-pages",
138
+ "rule": "ranges",
139
+ "mode": "text",
140
+ "in": "p. 123-125",
141
+ "out": "p. 123-125",
142
+ "note": "spec 0.5.0 ownership correction: moved from `dashes` to `ranges` (`rules: {ranges: true}` explicit — `ranges` defaults off) — this case tests G1-G5/cluster/binding behaviour that `dashes` no longer evaluates at all for a digit-flanked token; it was vacuous under `rule: \"dashes\"` after the split. Same: dash.range = none. Jacques André prints a hyphen in page intervals. `none` emits nothing AT ALL — the U+2060 joiners that a converted range carries in de-DE, de-CH and ru are not emitted either (dashes.md §2, §3.3.1), so the fr output is byte-identical to the input. ranges.md §2 is the current normative source for the `range: \"none\"` no-op.",
143
+ "rules": {
144
+ "ranges": true
145
+ }
146
+ },
147
+ {
148
+ "id": "fr-ranges-none-not-parenthetical",
149
+ "rule": "ranges",
150
+ "mode": "text",
151
+ "in": "34-36",
152
+ "out": "34-36",
153
+ "note": "spec 0.5.0 ownership correction: moved from `dashes` to `ranges` (`rules: {ranges: true}` explicit — `ranges` defaults off) — this case tests G1-G5/cluster/binding behaviour that `dashes` no longer evaluates at all for a digit-flanked token; it was vacuous under `rule: \"dashes\"` after the split. dashes.md §3.3 final paragraph: a range candidate is never reconsidered as a parenthetical, so `none` really means nothing happens. ranges.md §2 is the current normative source for the `range: \"none\"` no-op.",
154
+ "rules": {
155
+ "ranges": true
156
+ }
157
+ },
158
+ {
159
+ "id": "fr-dashes-compound-hyphen",
160
+ "rule": "dashes",
161
+ "mode": "text",
162
+ "in": "un porte-parole arc-en-ciel",
163
+ "out": "un porte-parole arc-en-ciel",
164
+ "note": "Guard P1: a bare unspaced hyphen is a compound word."
165
+ },
166
+ {
167
+ "id": "fr-dashes-guillemet-hyphen",
168
+ "rule": "dashes",
169
+ "mode": "text",
170
+ "in": "« - »",
171
+ "out": "« - »",
172
+ "note": "dashes.md §6 case 33, composition defect family 2: a U+00A0 to the right of a dash does not count as spacing, so the quoted hyphen is not read as a spaced parenthetical dash."
173
+ },
174
+ {
175
+ "id": "fr-ranges-iso-date",
176
+ "rule": "ranges",
177
+ "mode": "text",
178
+ "in": "Publié le 2026-08-15",
179
+ "out": "Publié le 2026-08-15",
180
+ "note": "spec 0.5.0 ownership correction: moved from `dashes` to `ranges` (`rules: {ranges: true}` explicit — `ranges` defaults off) — this case tests G1-G5/cluster/binding behaviour that `dashes` no longer evaluates at all for a digit-flanked token; it was vacuous under `rule: \"dashes\"` after the split. Guard G2 (chain) plus dash.range = none. ranges.md §3.2's G2 (chain) is the current normative guard rejecting this.",
181
+ "rules": {
182
+ "ranges": true
183
+ }
184
+ },
185
+ {
186
+ "id": "fr-hyphen-noop-compounds",
187
+ "rule": "hyphen",
188
+ "mode": "text",
189
+ "in": "Le porte-parole a parlé du savoir-faire.",
190
+ "out": "Le porte-parole a parlé du savoir-faire.",
191
+ "note": "hyphen.md §6 case 13: fr declares all three hyphen lists empty (the fr locale file cites Jacques André tableau 1, which marks the trait d'union as breakable), so the rule is a provable total no-op. A port that binds French hyphens with U+2011 must fail this case."
192
+ },
193
+ {
194
+ "id": "fr-hyphen-noop-names",
195
+ "rule": "hyphen",
196
+ "mode": "text",
197
+ "in": "Jean-Luc, COVID-19 et le e-mail",
198
+ "out": "Jean-Luc, COVID-19 et le e-mail",
199
+ "note": "Same: no entries, nothing to convert."
200
+ },
201
+ {
202
+ "id": "fr-hyphen-noop-existing-nbhyphen",
203
+ "rule": "hyphen",
204
+ "mode": "text",
205
+ "in": "porte‑parole",
206
+ "out": "porte‑parole",
207
+ "note": "A U+2011 the author wrote is left alone; it is also in dashes' INERT-DASH."
208
+ },
209
+ {
210
+ "id": "fr-quotes-primary",
211
+ "rule": "quotes",
212
+ "mode": "text",
213
+ "in": "Il a dit \"bonjour\".",
214
+ "out": "Il a dit « bonjour ».",
215
+ "note": "quotes.md §6 case 12: quotes emits the guillemets, nbsp then inserts quotes.primary.innerSpace. Note the inner space is U+00A0, not U+202F: the fr locale file follows the Lexique de l'Imprimerie nationale (espace insécable), and records the disagreement with widespread web usage and with JoliTypo, which set U+202F there."
216
+ },
217
+ {
218
+ "id": "fr-quotes-primary-roundtrip",
219
+ "rule": "quotes",
220
+ "mode": "text",
221
+ "in": "Il a dit « bonjour ».",
222
+ "out": "Il a dit « bonjour ».",
223
+ "note": "The output of the previous case is a fixed point."
224
+ },
225
+ {
226
+ "id": "fr-quotes-existing-guillemets-ordinary-space",
227
+ "rule": "quotes",
228
+ "mode": "text",
229
+ "in": "Il a dit « bonjour ».",
230
+ "out": "Il a dit « bonjour ».",
231
+ "note": "quotes.md §6 case 13 plus §3.7: no straight marks, so quotes does nothing and nbsp N8 converts the two U+0020 in place. Both input paths converge."
232
+ },
233
+ {
234
+ "id": "fr-quotes-nested",
235
+ "rule": "quotes",
236
+ "mode": "text",
237
+ "in": "\"Il a dit 'oui' hier.\"",
238
+ "out": "« Il a dit 'oui' hier. »",
239
+ "note": "Depth 1 primary « », depth 2 secondary “ ” whose innerSpace is none. spec 0.5.0: the inner 'oui' (3 letters, space-flanked) is preserved by the general ambiguous-medial-span veto — fr has no elisionIdioms entry for this shape."
240
+ },
241
+ {
242
+ "id": "fr-quotes-unbalanced",
243
+ "rule": "quotes",
244
+ "mode": "text",
245
+ "in": "Il a dit \"bonjour",
246
+ "out": "Il a dit \"bonjour",
247
+ "note": "quotes.md §3.6: an unmatched mark is left straight rather than guessed at."
248
+ },
249
+ {
250
+ "id": "fr-apostrophe-elision",
251
+ "rule": "apostrophe",
252
+ "mode": "text",
253
+ "in": "l'été et l'hiver",
254
+ "out": "l’été et l’hiver",
255
+ "note": "apostrophe.md §6 case 2: medial, letters on both sides."
256
+ },
257
+ {
258
+ "id": "fr-apostrophe-contractions",
259
+ "rule": "apostrophe",
260
+ "mode": "text",
261
+ "in": "Aujourd'hui c'est d'accord, n'est-ce pas",
262
+ "out": "Aujourd’hui c’est d’accord, n’est-ce pas",
263
+ "note": "Four medial apostrophes; the hyphen in n'est-ce is untouched."
264
+ },
265
+ {
266
+ "id": "fr-apostrophe-existing",
267
+ "rule": "apostrophe",
268
+ "mode": "text",
269
+ "in": "l’été",
270
+ "out": "l’été",
271
+ "note": "U+2019 is not in SQ and is never examined."
272
+ },
273
+ {
274
+ "id": "fr-symbols-copyright",
275
+ "rule": "symbols",
276
+ "mode": "text",
277
+ "in": "Copyright (c) 2026 Iurii Rogulia",
278
+ "out": "Copyright © 2026 Iurii Rogulia",
279
+ "note": "symbols.md §6 case 1."
280
+ },
281
+ {
282
+ "id": "fr-symbols-multiplication",
283
+ "rule": "symbols",
284
+ "mode": "text",
285
+ "in": "Une carte 3x5",
286
+ "out": "Une carte 3×5",
287
+ "note": "Digits on both sides, tight form."
288
+ },
289
+ {
290
+ "id": "fr-symbols-hex-literal",
291
+ "rule": "symbols",
292
+ "mode": "text",
293
+ "in": "Couleur 0x1F et masque 0x10",
294
+ "out": "Couleur 0x1F et masque 0x10",
295
+ "note": "Guards M3 and M4."
296
+ },
297
+ {
298
+ "id": "fr-symbols-revolution",
299
+ "rule": "symbols",
300
+ "mode": "text",
301
+ "in": "La (r)évolution ne sera pas télévisée",
302
+ "out": "La (r)évolution ne sera pas télévisée",
303
+ "note": "Guard S2: the code point after the match is a letter."
304
+ },
305
+ {
306
+ "id": "fr-nbsp-narrow-question",
307
+ "rule": "nbsp",
308
+ "mode": "text",
309
+ "in": "Ça va ?",
310
+ "out": "Ça va ?",
311
+ "note": "The distinction this package is built on: U+202F (narrow no-break space) before ? — Jacques André §5.1.1 note 21, « c'est la fine des typographes »."
312
+ },
313
+ {
314
+ "id": "fr-nbsp-narrow-exclamation",
315
+ "rule": "nbsp",
316
+ "mode": "text",
317
+ "in": "Bonjour !",
318
+ "out": "Bonjour !",
319
+ "note": "U+202F before !."
320
+ },
321
+ {
322
+ "id": "fr-nbsp-narrow-semicolon",
323
+ "rule": "nbsp",
324
+ "mode": "text",
325
+ "in": "Oui ; non",
326
+ "out": "Oui ; non",
327
+ "note": "U+202F before ; — « il faut une espace fine insécable avant le point-virgule » (Jacques André §5.2.3)."
328
+ },
329
+ {
330
+ "id": "fr-nbsp-wide-colon",
331
+ "rule": "nbsp",
332
+ "mode": "text",
333
+ "in": "Voici : la suite",
334
+ "out": "Voici : la suite",
335
+ "note": "And the counterpart: the colon takes U+00A0, NOT the narrow space. Jacques André §5.1.1 note 21 states the exception explicitly — « contre-exemple : l'espace avant le deux-points est en fait une espace normale insécable ». Most tools use one width for all four marks; this case fails them."
336
+ },
337
+ {
338
+ "id": "fr-nbsp-colon-inserted-with-no-space",
339
+ "rule": "nbsp",
340
+ "mode": "text",
341
+ "in": "Voici: la suite",
342
+ "out": "Voici : la suite",
343
+ "note": "N1 inserts when the author typed no space at all."
344
+ },
345
+ {
346
+ "id": "fr-nbsp-colon-in-url",
347
+ "rule": "nbsp",
348
+ "mode": "text",
349
+ "in": "Voir http://example.org: la suite",
350
+ "out": "Voir http://example.org : la suite",
351
+ "note": "nbsp.md §6 case 5: N1 guard 2 rejects the colon inside http:// (the next code point is /) and accepts the sentence colon."
352
+ },
353
+ {
354
+ "id": "fr-nbsp-colon-in-time",
355
+ "rule": "nbsp",
356
+ "mode": "text",
357
+ "in": "Le train de 12:30",
358
+ "out": "Le train de 12:30",
359
+ "note": "N1 guard 2: a digit follows the colon."
360
+ },
361
+ {
362
+ "id": "fr-nbsp-double-terminal",
363
+ "rule": "nbsp",
364
+ "mode": "text",
365
+ "in": "Vraiment ?!",
366
+ "out": "Vraiment ?!",
367
+ "note": "nbsp.md §6 case 6: N1/N2 guard 1, only the first mark of a run takes the space."
368
+ },
369
+ {
370
+ "id": "fr-nbsp-narrow-roundtrip",
371
+ "rule": "nbsp",
372
+ "mode": "text",
373
+ "in": "Bonjour ! Ça va : oui",
374
+ "out": "Bonjour ! Ça va : oui",
375
+ "note": "Correctly typeset text comes out byte-identical: both widths are recognised as already correct."
376
+ },
377
+ {
378
+ "id": "fr-nbsp-guillemet-inner",
379
+ "rule": "nbsp",
380
+ "mode": "text",
381
+ "in": "Il a dit «mot».",
382
+ "out": "Il a dit « mot ».",
383
+ "note": "nbsp.md §6 case 3 with the fr locale's actual innerSpace, which is nbsp (U+00A0), not narrow-nbsp."
384
+ },
385
+ {
386
+ "id": "fr-nbsp-guillemet-then-question",
387
+ "rule": "nbsp",
388
+ "mode": "text",
389
+ "in": "«?",
390
+ "out": "« ?",
391
+ "note": "nbsp.md §6 case 23 and §3.10.1: N1/N2 decline an index next to an opening quote glyph, so innerSpace (U+00A0) wins over the narrow space that ? would otherwise take. This oscillated between the two widths before the guard was added."
392
+ },
393
+ {
394
+ "id": "fr-nbsp-unit-percent",
395
+ "rule": "nbsp",
396
+ "mode": "text",
397
+ "in": "98 % des cas",
398
+ "out": "98 % des cas",
399
+ "note": "Jacques André §5.1.3: « 98_% »."
400
+ },
401
+ {
402
+ "id": "fr-nbsp-unit-km",
403
+ "rule": "nbsp",
404
+ "mode": "text",
405
+ "in": "20 km à pied",
406
+ "out": "20 km à pied",
407
+ "note": "N5, conversion of an existing space."
408
+ },
409
+ {
410
+ "id": "fr-nbsp-unit-never-inserts",
411
+ "rule": "nbsp",
412
+ "mode": "text",
413
+ "in": "20km à pied",
414
+ "out": "20km à pied",
415
+ "note": "nbsp.md §7.2: N5 converts, never inserts — adding a space is a content change."
416
+ },
417
+ {
418
+ "id": "fr-nbsp-before-number",
419
+ "rule": "nbsp",
420
+ "mode": "text",
421
+ "in": "voir art. 237 et fig. 3",
422
+ "out": "voir art. 237 et fig. 3",
423
+ "note": "N9, cited literally in the fr locale file to Jacques André §5.1.3."
424
+ },
425
+ {
426
+ "id": "fr-nbsp-before-word-mme",
427
+ "rule": "nbsp",
428
+ "mode": "text",
429
+ "in": "Mme Hugo et Mlle Durand",
430
+ "out": "Mme Hugo et Mlle Durand",
431
+ "note": "N10; both entries are three or more code points, so guard G-D does not apply."
432
+ },
433
+ {
434
+ "id": "fr-nbsp-monsieur-binds",
435
+ "rule": "nbsp",
436
+ "mode": "text",
437
+ "in": "M. Dupont est arrivé",
438
+ "out": "M. Dupont est arrivé",
439
+ "note": "nbsp.md §6 case 21: `M.` is inert in N10 (guard G-D: two code points, UPPER, dot, and fr has initialBinding \"single\" — spec 0.6.0, the field used to be the boolean bindInitials, true) and binds through N7 clause C1 instead, reaching the same U+00A0. `single` mode keeps the unconditional single-initial shape, which is what this exact case needs."
440
+ },
441
+ {
442
+ "id": "fr-nbsp-monsieur-lowercase-surname",
443
+ "rule": "nbsp",
444
+ "mode": "text",
445
+ "in": "M. dupont est arrivé",
446
+ "out": "M. dupont est arrivé",
447
+ "note": "nbsp.md §6 case 22 and §7.10: N7 C1 requires an UPPER code point after the space and N10 is inert for `M.`, so nothing binds. A documented gap, not an accident — French surnames are capitalised."
448
+ },
449
+ {
450
+ "id": "fr-nbsp-initials",
451
+ "rule": "nbsp",
452
+ "mode": "text",
453
+ "in": "N. Bourbaki",
454
+ "out": "N. Bourbaki",
455
+ "note": "N7 C1; cited to Jacques André §5.1.3."
456
+ },
457
+ {
458
+ "id": "fr-nbsp-abbreviation",
459
+ "rule": "nbsp",
460
+ "mode": "text",
461
+ "in": "p. ex. ceci",
462
+ "out": "p. ex. ceci",
463
+ "note": "N4 binds the space inside the listed multi-token abbreviation `p. ex.` and nothing else."
464
+ },
465
+ {
466
+ "id": "fr-nbsp-symbol-section",
467
+ "rule": "nbsp",
468
+ "mode": "text",
469
+ "in": "voir § 5",
470
+ "out": "voir § 5",
471
+ "note": "N6, afterSymbols."
472
+ },
473
+ {
474
+ "id": "fr-roundtrip-typeset-sentence",
475
+ "rule": "nbsp",
476
+ "mode": "text",
477
+ "in": "Il a dit : « bonjour », puis il est parti…",
478
+ "out": "Il a dit : « bonjour », puis il est parti…",
479
+ "note": "Byte-identical round trip over the whole pipeline: correct colon width, correct guillemet inner space, an existing ellipsis."
480
+ },
481
+ {
482
+ "id": "fr-roundtrip-plain-prose",
483
+ "rule": "spaces",
484
+ "mode": "text",
485
+ "in": "Le texte ordinaire ne change pas.",
486
+ "out": "Le texte ordinaire ne change pas.",
487
+ "note": "Nothing in this sentence is a candidate for any rule."
488
+ },
489
+ {
490
+ "id": "fr-roundtrip-dashes-and-range",
491
+ "rule": "dashes",
492
+ "mode": "text",
493
+ "in": "Les années 1914-1918 — quatre ans — furent longues.",
494
+ "out": "Les années 1914-1918 — quatre ans — furent longues.",
495
+ "note": "spec 0.5.0 ownership correction: the parenthetical half (`— quatre ans —`) is `dashes`' own concern (already em-spaced, unchanged) — the range half (`1914-1918`) is a no-op for the unrelated reason that fr's `dash.range` is \"none\", already covered on its own by `fr-ranges-none-years`/`fr-ranges-none-pages`/`fr-ranges-none-not-parenthetical` (rule:\"ranges\", explicit `rules: {ranges: true}`) and by the companion `fr-ranges-roundtrip-with-parenthetical` below, which proves the exact same sentence stays a no-op with `ranges` explicitly enabled too — this case alone does not, and must not be read to, prove anything about G1-G5."
496
+ },
497
+ {
498
+ "id": "fr-ranges-roundtrip-with-parenthetical",
499
+ "rule": "ranges",
500
+ "mode": "text",
501
+ "in": "Les années 1914-1918 — quatre ans — furent longues.",
502
+ "out": "Les années 1914-1918 — quatre ans — furent longues.",
503
+ "rules": {
504
+ "ranges": true
505
+ },
506
+ "note": "spec 0.5.0 ownership correction: companion to `fr-roundtrip-dashes-and-range`, explicitly opted in (`rules: {ranges: true}`) so the same realistic sentence is honestly proven a no-op with `ranges` actually evaluating it, not merely absent. `1914-1918` stays untouched because fr's `dash.range` is \"none\" (ranges.md §2), not because `ranges` was never asked."
507
+ },
508
+ {
509
+ "id": "fr-combined-sentence",
510
+ "rule": "nbsp",
511
+ "mode": "text",
512
+ "in": "Il a dit : \"Le trajet -- 20 km environ -- prendra 2 h...\" et il est parti.",
513
+ "out": "Il a dit : « Le trajet — 20 km environ — prendra 2 h… » et il est parti.",
514
+ "note": "A quotation, a parenthetical dash, an ellipsis and a number with a unit in one sentence: guillemets with U+00A0 inside, U+00A0 before the colon, em dashes with ordinary spaces, U+00A0 between 20 and km."
515
+ },
516
+ {
517
+ "id": "fr-quotes-030-already-guillemetted-with-spaces",
518
+ "rule": "quotes",
519
+ "mode": "text",
520
+ "in": "Il a dit « bonjour ».",
521
+ "out": "Il a dit « bonjour ».",
522
+ "note": "quotes.md §6 row 13, mandate 2's second requirement: the pair forms on the first application — the `quotes` rule itself emits no glyph edit here, because the guillemets are already correct. That does not make the full pipeline a no-op on this input: the later `nbsp` rule (nbsp.md §3.10, N8) still converts the two inner U+0020 spaces beside the guillemets to U+00A0 (not U+202F), per fr's `quotes.primary.innerSpace: \"nbsp\"`. The output above is the fixed point: a second full `transform` call on it is the no-op, not the first one."
523
+ },
524
+ {
525
+ "id": "fr-quotes-030-bug3a-witness",
526
+ "rule": "quotes",
527
+ "mode": "text",
528
+ "in": "« **\"",
529
+ "out": "« ** »",
530
+ "note": "quotes.md §6 row 3a, and the pinned pipeline-idempotency.md §6 witness «␣**\": « ∈ SPACE-RIGHT, so canClose's outer test skips the space nbsp would insert on either run, closing the run-1/run-2 asymmetry that used to make this witness fail."
531
+ },
532
+ {
533
+ "id": "fr-quotes-030-adversarial-interleaved",
534
+ "rule": "quotes",
535
+ "mode": "text",
536
+ "in": "«a“b«c»d”e»",
537
+ "out": "« a“b« c »d”e »",
538
+ "note": "quotes.md §6, ru/el/fr/fr-CA adversarial sweep: already-curly interleaved WIDE-only text (fr's primary and secondary are both WIDE). `quotes` forms nested pairs correctly; the visible spaces are `nbsp` normalising the primary «/» occurrences independently of pairing, verified idempotent."
539
+ },
540
+ {
541
+ "id": "fr-quotes-030-cobug-html-tag-boundary",
542
+ "rule": "quotes",
543
+ "mode": "html",
544
+ "in": "«\"<p class=\"x\">\"",
545
+ "out": "« \"<p class=\"x\">\"",
546
+ "note": "Implementation-discovered composition-obligation violation, not present in either design document, closed by quotes.md §3.2's V1 gapInsertable clause. Without it: quotes alone declines this pairing on the first application (the gate correctly catches that rendering it would place two literally-adjacent « — V1 would veto the pair on re-scan), but `nbsp` (order 70) still inserts a space after « unconditionally, and on the SECOND full-pipeline pass `quotes` sees the now-spaced text, the literal adjacency is gone, and the same pairing certifies — `nbsp` creating work for the earlier-ordered `quotes`, forbidden by pipeline-idempotency.md §2. Verified idempotent with the fix."
547
+ },
548
+ {
549
+ "id": "fr-quotes-030-cobug-double-open-then-closer",
550
+ "rule": "quotes",
551
+ "mode": "text",
552
+ "in": "««”",
553
+ "out": "« « ”",
554
+ "note": "The second implementation-discovered witness for the same class: two adjacent « (vetoed by V1, unchanged by `quotes` alone) each get a trailing space from `nbsp`'s unconditional N8 on the first pass; without the gapInsertable clause, `quotes` would then pair the second (now-spaced) « with the trailing ” on the SECOND pass, producing » where the first pass produced nothing — a fresh non-idempotent output. Verified idempotent with the fix."
555
+ },
556
+ {
557
+ "id": "fr-markdown-commonmark-prose-guillemets",
558
+ "rule": "quotes",
559
+ "mode": "markdown",
560
+ "dialect": "commonmark",
561
+ "in": "Il a dit \"bonjour\" et \"au revoir\".\n",
562
+ "out": "Il a dit « bonjour » et « au revoir ».\n",
563
+ "note": "Locale-dependent conversion applies inside a markdown span exactly as in text mode: fr's guillemets and no-break spaces, not en-US's curly quotes."
564
+ },
565
+ {
566
+ "id": "fr-markdown-commonmark-noop",
567
+ "rule": "nbsp",
568
+ "mode": "markdown",
569
+ "dialect": "commonmark",
570
+ "in": "Il a dit : « bonjour ».\n",
571
+ "out": "Il a dit : « bonjour ».\n",
572
+ "note": "modes.md §4: already-typeset fr prose, including the no-break space before the colon and inside the guillemets, is returned byte for byte."
573
+ },
574
+ {
575
+ "id": "fr-markdown-mdx-jsx-children",
576
+ "rule": "quotes",
577
+ "mode": "markdown",
578
+ "dialect": "mdx",
579
+ "in": "<Callout title=\"a\">Il a dit \"bonjour\"</Callout>\n",
580
+ "out": "<Callout title=\"a\">Il a dit « bonjour »</Callout>\n",
581
+ "note": "modes.md §3.7.3: JSX element children are processable prose, and locale-dependent conversion (fr's guillemets) applies there exactly as elsewhere."
582
+ },
583
+ {
584
+ "id": "fr-quotes-rock-n-roll",
585
+ "rule": "quotes",
586
+ "mode": "text",
587
+ "in": "rock 'n' roll",
588
+ "out": "rock 'n' roll",
589
+ "note": "spec 0.5.0: fr has no cited quotes.elisionIdioms entry for this idiom (quotes.md §7 item 8 — only en-US does, via Chicago Manual of Style Online and the American Heritage Dictionary). The general ambiguous-medial-span veto (quotes.md 3.2) preserves both marks as literal U+0027 rather than pairing them as an ordinary quotation, closing the false positive AUDIT_REMEDIATION_AND_RELEASE_PLAN.md 3.1 named for \"equivalent cases in several locales.\""
590
+ },
591
+ {
592
+ "id": "fr-ranges-none",
593
+ "rule": "ranges",
594
+ "mode": "text",
595
+ "in": "p. 123-125",
596
+ "out": "p. 123-125",
597
+ "rules": {
598
+ "ranges": true
599
+ },
600
+ "note": "spec 0.5.0: fr's dash.range is \"none\" (no verified convention), so `ranges` substitutes nothing even when explicitly enabled (ranges.md §2, §3.3) — the same invariant the pre-0.5.0 `dashes`-tagged no-op fixtures for this locale already proved for the combined rule."
601
+ }
602
+ ]
603
+ }