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,547 @@
1
+ {
2
+ "spec": "1.0.0",
3
+ "locale": "de-DE",
4
+ "cases": [
5
+ {
6
+ "id": "de-de-spaces-collapse-run",
7
+ "rule": "spaces",
8
+ "mode": "text",
9
+ "in": "Hallo Welt.",
10
+ "out": "Hallo Welt.",
11
+ "note": "spaces.md §6 case 1."
12
+ },
13
+ {
14
+ "id": "de-de-spaces-before-punctuation",
15
+ "rule": "spaces",
16
+ "mode": "text",
17
+ "in": "Hallo , Welt !",
18
+ "out": "Hallo, Welt!",
19
+ "note": "spaces.md §6 case 2. German takes no space before terminal punctuation, so nothing is put back."
20
+ },
21
+ {
22
+ "id": "de-de-spaces-empty-bracket-guard",
23
+ "rule": "spaces",
24
+ "mode": "text",
25
+ "in": "( )",
26
+ "out": "( )",
27
+ "note": "spaces.md §6 case 4b, the normative reading of §3.3: the empty-bracket guard forces replacement length 1, it does not skip the run."
28
+ },
29
+ {
30
+ "id": "de-de-spaces-hard-break-preserved",
31
+ "rule": "spaces",
32
+ "mode": "text",
33
+ "in": "Zeile eins \nZeile zwei",
34
+ "out": "Zeile eins \nZeile zwei",
35
+ "note": "spaces.md §6 case 5: a space run bordering a line terminator is skipped, so the Markdown hard break survives."
36
+ },
37
+ {
38
+ "id": "de-de-ellipsis-three-dots",
39
+ "rule": "ellipsis",
40
+ "mode": "text",
41
+ "in": "Warte... was?",
42
+ "out": "Warte… was?",
43
+ "note": "ellipsis.md §6 case 1."
44
+ },
45
+ {
46
+ "id": "de-de-ellipsis-many-dots",
47
+ "rule": "ellipsis",
48
+ "mode": "text",
49
+ "in": "Hmm.....",
50
+ "out": "Hmm…",
51
+ "note": "ellipsis.md §6 case 7."
52
+ },
53
+ {
54
+ "id": "de-de-ellipsis-two-dots-after-terminal",
55
+ "rule": "ellipsis",
56
+ "mode": "text",
57
+ "in": "Wirklich?..",
58
+ "out": "Wirklich?…",
59
+ "note": "ellipsis.md §6 case 3: de-DE has abbreviatedAfterTerminal = false, so `?..` is a typing slip — the opposite of the Russian rule."
60
+ },
61
+ {
62
+ "id": "de-de-ellipsis-existing",
63
+ "rule": "ellipsis",
64
+ "mode": "text",
65
+ "in": "Er ging…",
66
+ "out": "Er ging…",
67
+ "note": "ellipsis.md §6 case 6."
68
+ },
69
+ {
70
+ "id": "de-de-ellipsis-two-dot-run-inert",
71
+ "rule": "ellipsis",
72
+ "mode": "text",
73
+ "in": "Version 1..5",
74
+ "out": "Version 1..5",
75
+ "note": "ellipsis.md §6 case 5: a two-dot run between digits is never touched."
76
+ },
77
+ {
78
+ "id": "de-de-dashes-parenthetical-tight-input",
79
+ "rule": "dashes",
80
+ "mode": "text",
81
+ "in": "Der Plan--falls es einen gibt--scheitert.",
82
+ "out": "Der Plan – falls es einen gibt – scheitert.",
83
+ "note": "dashes.md §6 case 11: dash.parenthetical = en-spaced, Duden D 45 (Halbgeviertstrich mit Leerzeichen auf beiden Seiten)."
84
+ },
85
+ {
86
+ "id": "de-de-dashes-parenthetical-roundtrip",
87
+ "rule": "dashes",
88
+ "mode": "text",
89
+ "in": "Der Plan – falls es einen gibt – scheitert.",
90
+ "out": "Der Plan – falls es einen gibt – scheitert.",
91
+ "note": "dashes.md §6 case 12: already the target form."
92
+ },
93
+ {
94
+ "id": "de-de-dashes-authored-en-dash-tight-declined",
95
+ "rule": "dashes",
96
+ "mode": "text",
97
+ "in": "ein Wort–Wort Paar",
98
+ "out": "ein Wort–Wort Paar",
99
+ "note": "dashes.md §3.4 P5 (spec 0.6.0): a pure single authored U+2013 is declined unconditionally, even though de-DE's own `dash.parenthetical` target glyph is already U+2013 (`en-spaced`). Before P5, this tight authored en-dash would have been *re-spaced* (glyph already correct, only spacing missing) — P5 protects the token wholesale, spacing included, on the same operator decision recorded in dashes.md §8.8: the risk P5 guards against (an authored range/name dash silently read as ordinary parenthetical punctuation) is exactly as real when only spacing changes as when the glyph does too."
100
+ },
101
+ {
102
+ "id": "de-de-ranges",
103
+ "rule": "ranges",
104
+ "mode": "text",
105
+ "in": "Seiten 34-36",
106
+ "out": "Seiten 34⁠–⁠36",
107
+ "note": "Moved from `dashes` to `ranges` (spec 0.5.0, opt-in — `rules: {ranges: true}` explicit here since `ranges` defaults off). ranges.md §6 row 13 and case 44 (relocated from dashes.md §6 case 13 and case 44 through spec 0.4.1): dash.range = en-tight (Bundeskanzlei Rz. 234, „die Jahre 1939–1945“) — contrast ru, which sets an em dash here. The converted range is additionally wrapped in U+2060 word joiners (§3.3.1) so the interval cannot break across lines. The joiners are zero-width; spec/fixtures/.escaped/ is the only place they are visible.",
108
+ "rules": {
109
+ "ranges": true
110
+ }
111
+ },
112
+ {
113
+ "id": "de-de-ranges-years",
114
+ "rule": "ranges",
115
+ "mode": "text",
116
+ "in": "Die Jahre 1939-1945",
117
+ "out": "Die Jahre 1939⁠–⁠1945",
118
+ "note": "Moved from `dashes` to `ranges` (spec 0.5.0, opt-in — `rules: {ranges: true}` explicit here since `ranges` defaults off). Guards G4 (4,4) and G5 (non-decreasing) both pass (ranges.md §3.2), and the converted form carries its U+2060 joiners (ranges.md §3.3.1).",
119
+ "rules": {
120
+ "ranges": true
121
+ }
122
+ },
123
+ {
124
+ "id": "de-de-ranges-iso-date-and-isbn",
125
+ "rule": "ranges",
126
+ "mode": "text",
127
+ "in": "Erschienen 2026-08-15, ISBN 978-3-16-148410-0",
128
+ "out": "Erschienen 2026-08-15, ISBN 978-3-16-148410-0",
129
+ "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 §6 case 6: guard G2 and the cluster guard. ranges.md §3.2's G2 (chain) is the current normative guard rejecting this.",
130
+ "rules": {
131
+ "ranges": true
132
+ }
133
+ },
134
+ {
135
+ "id": "de-de-dashes-covid",
136
+ "rule": "dashes",
137
+ "mode": "text",
138
+ "in": "COVID-19",
139
+ "out": "COVID-19",
140
+ "note": "spec 0.5.0 ownership correction: split from the former combined `de-de-dashes-product-names` case (which bundled a genuine `dashes` witness with a genuine `ranges` witness in one string, letting one no-op stand in for two independent guarantees). This half tests `dashes` alone: `cp[L]` is the letter `D`, so the hyphen is a mixed letter/digit token, P1's compound-word guard declines it (dashes.md §3.4) — never a range candidate, since a range candidate needs a `DIGIT` on *both* sides (dashes.md §3.3, first paragraph). See `de-de-ranges-iso-8859` for the range-guard half."
141
+ },
142
+ {
143
+ "id": "de-de-ranges-iso-8859",
144
+ "rule": "ranges",
145
+ "mode": "text",
146
+ "in": "ISO 8859-1",
147
+ "out": "ISO 8859-1",
148
+ "rules": {
149
+ "ranges": true
150
+ },
151
+ "note": "spec 0.5.0 ownership correction: split from the former combined `de-de-dashes-product-names` case. This half tests `ranges` alone, explicitly enabled: `8859-1` is digit-flanked, a genuine range candidate — G4 (equal-run-length, or the (1,2) branch) fails on (4,1), so it is declined (ranges.md §3.2). Without `rules: {ranges: true}` this input is already a no-op for the unrelated reason that `dashes` never reaches a digit-flanked token at all (dashes.md §1) — asserting it under `rule: \"dashes\"` would prove nothing about G4."
152
+ },
153
+ {
154
+ "id": "de-de-dashes-authored-em-dash-converts",
155
+ "rule": "dashes",
156
+ "mode": "text",
157
+ "in": "Der Plan—falls es einen gibt—fällt.",
158
+ "out": "Der Plan – falls es einen gibt – fällt.",
159
+ "note": "dashes.md §6 case 41, spec 0.2.0. Spec 0.1.0's authored-dash guard (and a briefly-shipped 0.2.0 narrowing that preserved only length) is fully retired: de-DE's parenthetical is `en-spaced`, so the author's em dash is promoted to it exactly as a hyphen-typed one would be — length included. See dashes.md §7 item 14 for why length stopped being treated as reliably authorial."
160
+ },
161
+ {
162
+ "id": "de-de-dashes-mixed-run-converts",
163
+ "rule": "dashes",
164
+ "mode": "text",
165
+ "in": "a-–b",
166
+ "out": "a – b",
167
+ "note": "dashes.md §6 case 42, spec 0.2.0. A run mixing `DASH` glyphs (U+002D and U+2013 here) is a single token, promoted exactly as the equivalent hyphen run (`a--b`) would be — P3 admits a `k = 2` run regardless of spacing. Spec 0.1.0's authored-dash guard, which declined any run containing U+2013/U+2014 outright, is retired in 0.2.0 (dashes.md §3.2 step 2a)."
168
+ },
169
+ {
170
+ "id": "de-de-dashes-asymmetric",
171
+ "rule": "dashes",
172
+ "mode": "text",
173
+ "in": "mit --force überschreiben",
174
+ "out": "mit --force überschreiben",
175
+ "note": "dashes.md §6 case 8: the symmetry guard."
176
+ },
177
+ {
178
+ "id": "de-de-dashes-cluster-inert",
179
+ "rule": "dashes",
180
+ "mode": "text",
181
+ "in": "1914-1918--annexation",
182
+ "out": "1914-1918--annexation",
183
+ "note": "dashes.md §6 case 20, shipped idempotency defect (a): one cluster with three dash runs is inert in its entirety."
184
+ },
185
+ {
186
+ "id": "de-de-ranges-cluster-inert",
187
+ "rule": "ranges",
188
+ "mode": "text",
189
+ "in": "1914-1918--annexation",
190
+ "out": "1914-1918--annexation",
191
+ "rules": {
192
+ "ranges": true
193
+ },
194
+ "note": "spec 0.5.0 ownership correction: companion to `de-de-dashes-cluster-inert`, added so the range-candidate half of the same cluster is honestly covered too (one no-op must not stand in for two independent guarantees). Explicitly opted in (`rules: {ranges: true}`): the `1914-1918` range candidate sits in the same three-dash-run cluster as the `--` before `annexation`, so `ranges`' own cluster guard (dash-shared.ts, shared with `dashes`) declines it too — cluster guard, ranges.md §3.1."
195
+ },
196
+ {
197
+ "id": "de-de-dashes-spacing-transition-guard-spaced",
198
+ "rule": "dashes",
199
+ "mode": "text",
200
+ "in": "a–1 - 1",
201
+ "out": "a–1 - 1",
202
+ "note": "dashes.md §6 case 24, shipped idempotency defect (c): guard T1 at distance 2."
203
+ },
204
+ {
205
+ "id": "de-de-ranges-spacing-transition-guard-spaced",
206
+ "rule": "ranges",
207
+ "mode": "text",
208
+ "in": "a–1 - 1",
209
+ "out": "a–1 - 1",
210
+ "rules": {
211
+ "ranges": true
212
+ },
213
+ "note": "spec 0.5.0 ownership correction: companion to `de-de-dashes-spacing-transition-guard-spaced`. Explicitly opted in (`rules: {ranges: true}`): the trailing `1 - 1` is a genuine range candidate (spaced, digit-flanked), and it is declined independently of `dashes`' own T1 guard — G2 (\"no chain\", ranges.md §3.2) rejects it directly, because its effective left neighbour is the earlier em/en dash."
214
+ },
215
+ {
216
+ "id": "de-de-dashes-spacing-transition-guard-tight",
217
+ "rule": "dashes",
218
+ "mode": "text",
219
+ "in": "a–1-1",
220
+ "out": "a–1-1",
221
+ "note": "dashes.md §6 case 26: the tight sibling, guard T1 at distance 1."
222
+ },
223
+ {
224
+ "id": "de-de-ranges-spacing-transition-guard-tight",
225
+ "rule": "ranges",
226
+ "mode": "text",
227
+ "in": "a–1-1",
228
+ "out": "a–1-1",
229
+ "rules": {
230
+ "ranges": true
231
+ },
232
+ "note": "spec 0.5.0 ownership correction: companion to `de-de-dashes-spacing-transition-guard-tight`. Explicitly opted in (`rules: {ranges: true}`): `1-1` is a genuine range candidate, and the whole `–1-1` span is one cluster with two dash runs, so `ranges`' own cluster guard declines it independently of `dashes`' step (cluster guard, ranges.md §3.1)."
233
+ },
234
+ {
235
+ "id": "de-de-dashes-composition-guard-t2-dot",
236
+ "rule": "dashes",
237
+ "mode": "text",
238
+ "in": ".--.",
239
+ "out": ".--.",
240
+ "note": "dashes.md §6 case 28, composition defect family 1: an en-spaced form would put a U+0020 exactly where `spaces` deletes one."
241
+ },
242
+ {
243
+ "id": "de-de-dashes-composition-guard-t2-brackets",
244
+ "rule": "dashes",
245
+ "mode": "text",
246
+ "in": "(--a und a--)",
247
+ "out": "(--a und a--)",
248
+ "note": "dashes.md §6 cases 30 and 31: guard T2 on the left (open bracket) and on the right (close bracket)."
249
+ },
250
+ {
251
+ "id": "de-de-hyphen-noop-compounds",
252
+ "rule": "hyphen",
253
+ "mode": "text",
254
+ "in": "Eine E-Mail-Adresse und der Nord-Süd-Verkehr",
255
+ "out": "Eine E-Mail-Adresse und der Nord-Süd-Verkehr",
256
+ "note": "hyphen.md §6 case 13: de-DE declares all three hyphen lists empty. The locale file cites Bundeskanzlei Rz. 223/224 — German has no closed list of morphemes with an unbreakable hyphen, and the one documented case (the Ergänzungsstrich) is open-ended and cannot be a literal word list. The rule is a provable total no-op; a port that implements it anyway must fail here."
257
+ },
258
+ {
259
+ "id": "de-de-hyphen-noop-supplementary",
260
+ "rule": "hyphen",
261
+ "mode": "text",
262
+ "in": "Papierproduktion und -handel",
263
+ "out": "Papierproduktion und -handel",
264
+ "note": "Exactly the Ergänzungsstrich of Rz. 224: normatively unbreakable, and deliberately still not bound, because it cannot be expressed as a list entry. Recorded so the gap is visible rather than silent."
265
+ },
266
+ {
267
+ "id": "de-de-quotes-primary",
268
+ "rule": "quotes",
269
+ "mode": "text",
270
+ "in": "Er sagte \"hallo\" zweimal.",
271
+ "out": "Er sagte „hallo“ zweimal.",
272
+ "note": "Duden D 5: Gänsefüßchen „…“. Note that the closing glyph U+201C is the one English uses to OPEN — the pairing, not the glyph shape, decides."
273
+ },
274
+ {
275
+ "id": "de-de-quotes-nested",
276
+ "rule": "quotes",
277
+ "mode": "text",
278
+ "in": "\"Er sagte 'nein' zu mir\", notierte sie.",
279
+ "out": "„Er sagte ‚nein‘ zu mir“, notierte sie.",
280
+ "note": "Duden D 12 / Amtliches Regelwerk §79 E2: halbe Anführungszeichen at depth 2."
281
+ },
282
+ {
283
+ "id": "de-de-quotes-roundtrip",
284
+ "rule": "quotes",
285
+ "mode": "text",
286
+ "in": "„Er sagte ‚nein‘ zu mir“, notierte sie.",
287
+ "out": "„Er sagte ‚nein‘ zu mir“, notierte sie.",
288
+ "note": "quotes.md §3.8: existing curly glyphs are not candidates and are not re-paired."
289
+ },
290
+ {
291
+ "id": "de-de-quotes-no-inner-space",
292
+ "rule": "quotes",
293
+ "mode": "text",
294
+ "in": "Das Wort „Beispiel“",
295
+ "out": "Das Wort „Beispiel“",
296
+ "note": "quotes.innerSpace is none for both German pairs, so N8 inserts nothing."
297
+ },
298
+ {
299
+ "id": "de-de-quotes-unbalanced",
300
+ "rule": "quotes",
301
+ "mode": "text",
302
+ "in": "Er sagte \"hallo",
303
+ "out": "Er sagte \"hallo",
304
+ "note": "quotes.md §3.6."
305
+ },
306
+ {
307
+ "id": "de-de-apostrophe-medial",
308
+ "rule": "apostrophe",
309
+ "mode": "text",
310
+ "in": "Ich hab's gesehen",
311
+ "out": "Ich hab’s gesehen",
312
+ "note": "apostrophe.md §6 case 1: letters on both sides."
313
+ },
314
+ {
315
+ "id": "de-de-apostrophe-existing",
316
+ "rule": "apostrophe",
317
+ "mode": "text",
318
+ "in": "Ich hab’s gesehen",
319
+ "out": "Ich hab’s gesehen",
320
+ "note": "U+2019 is never examined."
321
+ },
322
+ {
323
+ "id": "de-de-apostrophe-prime-guard",
324
+ "rule": "apostrophe",
325
+ "mode": "text",
326
+ "in": "Er ist 6' 2\" groß.",
327
+ "out": "Er ist 6' 2\" groß.",
328
+ "note": "apostrophe.md §6 case 6 and quotes.md §4: a foot mark survives, and so does the inch mark, which nothing can pair."
329
+ },
330
+ {
331
+ "id": "de-de-symbols-copyright",
332
+ "rule": "symbols",
333
+ "mode": "text",
334
+ "in": "Copyright (c) 2026 Iurii Rogulia",
335
+ "out": "Copyright © 2026 Iurii Rogulia",
336
+ "note": "symbols.md §6 case 1."
337
+ },
338
+ {
339
+ "id": "de-de-symbols-trademark",
340
+ "rule": "symbols",
341
+ "mode": "text",
342
+ "in": "Acme(tm) und Acme (R)",
343
+ "out": "Acme™ und Acme ®",
344
+ "note": "symbols.md §6 case 2: the (tm) rows are exempt from guard S1."
345
+ },
346
+ {
347
+ "id": "de-de-symbols-call-position",
348
+ "rule": "symbols",
349
+ "mode": "text",
350
+ "in": "f(c) liefert c",
351
+ "out": "f(c) liefert c",
352
+ "note": "symbols.md §6 case 4: guard S1."
353
+ },
354
+ {
355
+ "id": "de-de-symbols-multiplication",
356
+ "rule": "symbols",
357
+ "mode": "text",
358
+ "in": "Auflösung 1920x1080",
359
+ "out": "Auflösung 1920×1080",
360
+ "note": "symbols.md §6 case 7."
361
+ },
362
+ {
363
+ "id": "de-de-symbols-hex-literal",
364
+ "rule": "symbols",
365
+ "mode": "text",
366
+ "in": "Farbe 0x1F, Maske 0xFF, Offset 0x10",
367
+ "out": "Farbe 0x1F, Maske 0xFF, Offset 0x10",
368
+ "note": "symbols.md §6 case 6: guards M3 and M4."
369
+ },
370
+ {
371
+ "id": "de-de-nbsp-abbreviation-zb",
372
+ "rule": "nbsp",
373
+ "mode": "text",
374
+ "in": "z. B. so",
375
+ "out": "z. B. so",
376
+ "note": "N4 binds the internal space of the listed abbreviation `z. B.` — DIN 5008 / Duden D 1."
377
+ },
378
+ {
379
+ "id": "de-de-nbsp-abbreviation-zb-berlin",
380
+ "rule": "nbsp",
381
+ "mode": "text",
382
+ "in": "z. B. Berlin",
383
+ "out": "z. B. Berlin",
384
+ "note": "nbsp.md §6 case 14, the live false positive this guard was written for: N4 binds the space INSIDE `z. B.`, and the space after `B.` stays breakable. Without guard C1-a the initials sub-rule read `B.` as an initial and `Berlin` as a surname and produced `z.⍽B.⍽Berlin` — a false positive on ordinary prose, which is the ship-blocking class for this project."
385
+ },
386
+ {
387
+ "id": "de-de-nbsp-abbreviation-zb-roundtrip",
388
+ "rule": "nbsp",
389
+ "mode": "text",
390
+ "in": "z. B. Berlin",
391
+ "out": "z. B. Berlin",
392
+ "note": "nbsp.md §6 case 15: N4's match is space-lenient, so its own output is recognised as already correct."
393
+ },
394
+ {
395
+ "id": "de-de-nbsp-abbreviation-dh",
396
+ "rule": "nbsp",
397
+ "mode": "text",
398
+ "in": "d. h. genau so",
399
+ "out": "d. h. genau so",
400
+ "note": "N4 on `d. h.`."
401
+ },
402
+ {
403
+ "id": "de-de-nbsp-abbreviation-three-part",
404
+ "rule": "nbsp",
405
+ "mode": "text",
406
+ "in": "i. d. R. nicht",
407
+ "out": "i. d. R. nicht",
408
+ "note": "N4 binds every internal space of a listed abbreviation, however many there are."
409
+ },
410
+ {
411
+ "id": "de-de-nbsp-units",
412
+ "rule": "nbsp",
413
+ "mode": "text",
414
+ "in": "Es sind 20 km bis 5 %",
415
+ "out": "Es sind 20 km bis 5 %",
416
+ "note": "nbsp.md §6 case 16: N5 twice."
417
+ },
418
+ {
419
+ "id": "de-de-nbsp-unit-never-inserts",
420
+ "rule": "nbsp",
421
+ "mode": "text",
422
+ "in": "Es sind 20km",
423
+ "out": "Es sind 20km",
424
+ "note": "nbsp.md §6 case 17 and §7.2: N5 converts, never inserts — even though Duden requires the space, adding one is a content change."
425
+ },
426
+ {
427
+ "id": "de-de-nbsp-unit-letter-guard",
428
+ "rule": "nbsp",
429
+ "mode": "text",
430
+ "in": "H2 O ist kein Wert",
431
+ "out": "H2 O ist kein Wert",
432
+ "note": "nbsp.md §6 case 18: N5 step 4, the digit run is preceded by a letter."
433
+ },
434
+ {
435
+ "id": "de-de-nbsp-before-number-seite",
436
+ "rule": "nbsp",
437
+ "mode": "text",
438
+ "in": "siehe S. 12",
439
+ "out": "siehe S. 12",
440
+ "note": "N9: `S.` + number, cited to Bundeskanzlei Rz. 251 („S. 17 f.“) and required by docs/PLAN.md §7. Note that guard G-D (which would make a two-code-point UPPER+dot entry inert) belongs to N10 only — N9 has no such guard, so `S.` binds forward to a number even though it looks exactly like an initial."
441
+ },
442
+ {
443
+ "id": "de-de-nbsp-before-number-case-sensitive",
444
+ "rule": "nbsp",
445
+ "mode": "text",
446
+ "in": "siehe s. 12",
447
+ "out": "siehe s. 12",
448
+ "note": "nbsp.md §6 case 20 in spirit: N9 matches exactly, with no case leniency at all. `s.` is not `S.`."
449
+ },
450
+ {
451
+ "id": "de-de-nbsp-symbol-number",
452
+ "rule": "nbsp",
453
+ "mode": "text",
454
+ "in": "siehe § 12 und Nr. 5",
455
+ "out": "siehe § 12 und Nr. 5",
456
+ "note": "Two halves with different evidence. `§` is in afterSymbols and binds through N6. `Nr.` binds through nothing: it was removed from the de-DE locale file entirely, because no consulted source states a spacing rule for it — the Schreibweisungen never mention it (it is absent from Rz. 439's closed list of Begriffszeichen and from the Sachregister), Duden classifies it as an abbreviation, which refutes afterSymbols but prescribes no spacing, and DIN 5008 is paywalled and unread. Under PLAN.md §6.1 an entry whose citation does not cover it does not ship. The gap is evidentiary, not typographic."
457
+ },
458
+ {
459
+ "id": "de-de-nbsp-nr-unbound-pending-citation",
460
+ "rule": "nbsp",
461
+ "mode": "text",
462
+ "in": "siehe Nr. 5",
463
+ "out": "siehe Nr. 5",
464
+ "note": "Pinned on its own so it is easy to find and easy to flip: if a contributor obtains DIN 5008 and it states the binding, `Nr.` goes into de-DE's nbsp.beforeNumber (NOT afterSymbols — Duden calls it an abbreviation, and Rz. 439's Begriffszeichen list excludes it) and this case's `out` becomes `siehe Nr.` + U+00A0 + `5`. Until then the space stays breakable, because moving it on class membership alone would be inference dressed as citation."
465
+ },
466
+ {
467
+ "id": "de-de-nbsp-before-word-sankt",
468
+ "rule": "nbsp",
469
+ "mode": "text",
470
+ "in": "St. Gallen liegt am See",
471
+ "out": "St. Gallen liegt am See",
472
+ "note": "N10: `St.` is three code points, so guard G-D does not apply and it binds to the following word — Bundeskanzlei Rz. 438."
473
+ },
474
+ {
475
+ "id": "de-de-nbsp-initials",
476
+ "rule": "nbsp",
477
+ "mode": "text",
478
+ "in": "J. K. Rowling",
479
+ "out": "J. K. Rowling",
480
+ "note": "N7 clause C1 twice — DIN 5008."
481
+ },
482
+ {
483
+ "id": "de-de-nbsp-no-french-spacing",
484
+ "rule": "nbsp",
485
+ "mode": "text",
486
+ "in": "Wirklich ? Ja !",
487
+ "out": "Wirklich? Ja!",
488
+ "note": "Both punctuation lists are empty for German: `spaces` removes the space and nothing puts one back. The same input in fr gains U+202F."
489
+ },
490
+ {
491
+ "id": "de-de-roundtrip-typeset-sentence",
492
+ "rule": "nbsp",
493
+ "mode": "text",
494
+ "in": "z. B. sind es 20 km – also nicht weit.",
495
+ "out": "z. B. sind es 20 km – also nicht weit.",
496
+ "note": "Byte-identical round trip: a bound abbreviation, a bound unit and an en-spaced parenthetical dash."
497
+ },
498
+ {
499
+ "id": "de-de-roundtrip-plain-prose",
500
+ "rule": "spaces",
501
+ "mode": "text",
502
+ "in": "Ein ganz gewöhnlicher Satz bleibt unverändert.",
503
+ "out": "Ein ganz gewöhnlicher Satz bleibt unverändert.",
504
+ "note": "No candidate for any rule."
505
+ },
506
+ {
507
+ "id": "de-de-roundtrip-quotes-and-range",
508
+ "rule": "quotes",
509
+ "mode": "text",
510
+ "in": "„Die Jahre 1939–1945“, sagte er.",
511
+ "out": "„Die Jahre 1939–1945“, sagte er.",
512
+ "note": "Correct German quotation marks and a tight en-dash range already exactly in de-DE's `en-tight` form come out byte-identical — and, in particular, with no U+2060 joiners, where the hyphen-typed `1939-1945` above gets them. That asymmetry is deliberate (§3.3.1, §7.14): the rule never makes an edit whose entire content is invisible. A joiner rides along with a real conversion; it is never the only thing an edit does, because an invisible-only change to text that was already right is unreviewable by hand, and hand review is the M4 criterion. §3.3.1's invisible-edit test decides this by computing the unbound replacement first and comparing it to the input — not by asking whether the dash was authored, which spec 0.2.0's retirement of §3.2 step 2a made irrelevant to the question."
513
+ },
514
+ {
515
+ "id": "de-de-combined-sentence",
516
+ "rule": "nbsp",
517
+ "mode": "text",
518
+ "in": "Er sagte: \"Die Strecke -- rund 20 km -- war lang...\" und ging.",
519
+ "out": "Er sagte: „Die Strecke – rund 20 km – war lang…“ und ging.",
520
+ "note": "A quotation, a parenthetical dash, an ellipsis and a number with a unit in one sentence: Gänsefüßchen without inner space, en-spaced dashes, U+2026, and U+00A0 between 20 and km."
521
+ },
522
+ {
523
+ "id": "de-de-quotes-030-foreign-guillemets",
524
+ "rule": "quotes",
525
+ "mode": "text",
526
+ "in": "Er sagte «Wort» leise.",
527
+ "out": "Er sagte „Wort“ leise.",
528
+ "note": "quotes.md §6 row C, required scenario (c): foreign guillemets convert to de-DE's own primary pair."
529
+ },
530
+ {
531
+ "id": "de-de-quotes-030-foreign-guillemets-spaced",
532
+ "rule": "quotes",
533
+ "mode": "text",
534
+ "in": "Er sagte « Wort » leise.",
535
+ "out": "Er sagte „Wort“ leise.",
536
+ "note": "quotes.md §6 row C2: same as the previous case, plus both inner runs are deleted (landings W and t, both in DELETE-LANDING) since de-DE's innerSpace is none."
537
+ },
538
+ {
539
+ "id": "de-de-quotes-rock-n-roll",
540
+ "rule": "quotes",
541
+ "mode": "text",
542
+ "in": "rock 'n' roll",
543
+ "out": "rock 'n' roll",
544
+ "note": "spec 0.5.0: de-DE 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.\""
545
+ }
546
+ ]
547
+ }