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,239 @@
1
+ {
2
+ "spec": "1.0.0",
3
+ "locale": "el",
4
+ "cases": [
5
+ {
6
+ "id": "el-spaces-collapse-run",
7
+ "rule": "spaces",
8
+ "mode": "text",
9
+ "in": "Ελλάδα και Κύπρος",
10
+ "out": "Ελλάδα και Κύπρος",
11
+ "note": "spaces.md §6 case 1: a run of ordinary spaces bounded by CONTENT collapses to one."
12
+ },
13
+ {
14
+ "id": "el-spaces-before-erotimatiko",
15
+ "rule": "spaces",
16
+ "mode": "text",
17
+ "in": "Τι κάνεις ;",
18
+ "out": "Τι κάνεις;",
19
+ "note": "spaces.md §3.5. The Greek question mark is written U+003B, which is already in STRIP-BEFORE for the Latin semicolon, and Greek prescribes no space before it either — so the ambiguity costs nothing and no rule has to tell the two readings apart. Unlike French, nbsp puts nothing back."
20
+ },
21
+ {
22
+ "id": "el-spaces-before-punctuation",
23
+ "rule": "spaces",
24
+ "mode": "text",
25
+ "in": "Καλημέρα , κόσμε !",
26
+ "out": "Καλημέρα, κόσμε!",
27
+ "note": "spaces.md §6 case 2. Greek takes no space before terminal punctuation and the source names French as the contrast, so nothing is re-inserted at order 70."
28
+ },
29
+ {
30
+ "id": "el-spaces-brackets",
31
+ "rule": "spaces",
32
+ "mode": "text",
33
+ "in": "Μια ( παρένθεση , εδώ ) τέλος",
34
+ "out": "Μια (παρένθεση, εδώ) τέλος",
35
+ "note": "spaces.md §3.6 worked trace, in Greek."
36
+ },
37
+ {
38
+ "id": "el-spaces-compat-question-mark-untouched",
39
+ "rule": "spaces",
40
+ "mode": "text",
41
+ "in": "Τι κάνεις ;",
42
+ "out": "Τι κάνεις ;",
43
+ "note": "spaces.md §3.5 part 3: U+037E is in no class of any rule. The space before it survives and the character is NOT rewritten to the U+003B it canonically decomposes to — that would be normalisation (ARCHITECTURE.md §4.3). Contrast el-spaces-before-erotimatiko, where the same character spelled U+003B does lose its space. This case is why spec/fixtures/el.json is deliberately NOT in NFC and validate-spec warns about it. Precisely: the character canonically decomposes, so any file holding it as a LITERAL is non-NFC. A JSON \\uXXXX escape would keep the file itself NFC while parsing to the identical string, but literals are what all nine fixture files use, and spec/fixtures/.escaped/ already provides the reviewable escaped mirror, so the literal stays. The warning is the intended state, not a defect. Do not 'fix' it by normalising the file: that silently rewrites this case into a duplicate of the recommended-spelling one, which still passes. scripts/validate-spec.mjs now asserts the code point positively by case id so that CI fails instead."
44
+ },
45
+ {
46
+ "id": "el-spaces-ano-teleia-untouched",
47
+ "rule": "spaces",
48
+ "mode": "text",
49
+ "in": "πρώτο · δεύτερο",
50
+ "out": "πρώτο · δεύτερο",
51
+ "note": "spaces.md §3.5: U+00B7 is deliberately NOT in STRIP-BEFORE. Greek prescribes no space before the άνω τελεία, but U+00B7 is a deliberate spaced separator in other locales (Home · About, the Catalan and French interpunct) and this rule reads no locale data. The stray space survives; that is the accepted cost."
52
+ },
53
+ {
54
+ "id": "el-spaces-compat-ano-teleia-untouched",
55
+ "rule": "spaces",
56
+ "mode": "text",
57
+ "in": "πρώτο · δεύτερο",
58
+ "out": "πρώτο · δεύτερο",
59
+ "note": "spaces.md §3.5: the compatibility άνω τελεία behaves identically to U+00B7. Adding only U+0387 to STRIP-BEFORE would split two canonically equivalent characters and would fix the spelling Unicode discourages while leaving the recommended one broken. This case is why spec/fixtures/el.json is deliberately NOT in NFC and validate-spec warns about it. Precisely: the character canonically decomposes, so any file holding it as a LITERAL is non-NFC. A JSON \\uXXXX escape would keep the file itself NFC while parsing to the identical string, but literals are what all nine fixture files use, and spec/fixtures/.escaped/ already provides the reviewable escaped mirror, so the literal stays. The warning is the intended state, not a defect. Do not 'fix' it by normalising the file: that silently rewrites this case into a duplicate of the recommended-spelling one, which still passes. scripts/validate-spec.mjs now asserts the code point positively by case id so that CI fails instead."
60
+ },
61
+ {
62
+ "id": "el-ellipsis-three-dots",
63
+ "rule": "ellipsis",
64
+ "mode": "text",
65
+ "in": "Περίμενε... τι έγινε",
66
+ "out": "Περίμενε… τι έγινε",
67
+ "note": "ellipsis.md §6 case 1: a three-dot run becomes one U+2026."
68
+ },
69
+ {
70
+ "id": "el-ellipsis-not-abbreviated-after-terminal",
71
+ "rule": "ellipsis",
72
+ "mode": "text",
73
+ "in": "Πράγματι?..",
74
+ "out": "Πράγματι?…",
75
+ "note": "ellipsis.md §6 case 3, the abbreviatedAfterTerminal = false branch. The Greek source requires exactly three dots always («πάντοτε τρεις»), so Greek takes the non-abbreviating branch and the two-dot run after ? becomes a full U+2026 — the opposite of ru."
76
+ },
77
+ {
78
+ "id": "el-ellipsis-greek-question-mark-not-terminal",
79
+ "rule": "ellipsis",
80
+ "mode": "text",
81
+ "in": "Πράγματι;..",
82
+ "out": "Πράγματι;..",
83
+ "note": "spaces.md §7.8. ellipsis.md §3.1 defines TERMINAL as {U+0021, U+003F}, and a Greek question is written U+003B, so the two-dot run after it is left alone — while el-ellipsis-not-abbreviated-after-terminal, spelled with the U+003F that Greek does not use for questions, does convert. Adding U+003B to TERMINAL was researched and REFUSED: it would be a regression in Russian, where Лопатин §154 and Розенталь §68.1 tie the two-dot form to «?» and «!» by name and no source extends it to the semicolon, and no Greek source asks for it either — the Greek authorities give no rule at all for αποσιωπητικά meeting the ερωτηματικό. Unsupported on both sides, so the input round-trips unchanged, which is what an unspecified case should do. Note that the correctly written three-dot form «Πράγματι;...» already converts, needing nothing from TERMINAL."
84
+ },
85
+ {
86
+ "id": "el-ranges-noop",
87
+ "rule": "ranges",
88
+ "mode": "text",
89
+ "in": "άτομα ηλικίας 25-45 ετών",
90
+ "out": "άτομα ηλικίας 25-45 ετών",
91
+ "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 33. dash.range is \"none\", so the token is classified as a range and nothing is emitted. The source prints an en dash here but permits the hyphen in the same clause, so substituting would express a preference the citation does not carry. ranges.md §2 is the current normative source for the `range: \"none\"` no-op.",
92
+ "rules": {
93
+ "ranges": true
94
+ }
95
+ },
96
+ {
97
+ "id": "el-dashes-parenthetical-noop",
98
+ "rule": "dashes",
99
+ "mode": "text",
100
+ "in": "Το σχέδιο - αν υπάρχει - αποτυγχάνει.",
101
+ "out": "Το σχέδιο - αν υπάρχει - αποτυγχάνει.",
102
+ "note": "dashes.md §6 case 34. dash.parenthetical is \"none\": the source prescribes spaces outside the dash pair and none inside, which no value of the enum expresses."
103
+ },
104
+ {
105
+ "id": "el-ranges-existing-en-dash-noop",
106
+ "rule": "ranges",
107
+ "mode": "text",
108
+ "in": "την περίοδο 1989–1991",
109
+ "out": "την περίοδο 1989–1991",
110
+ "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 35: an en dash already in the form the source prints. Greek is the first locale with \"none\" on both fields, so this rule is a provable total no-op for it. ranges.md §2 is the current normative source for the `range: \"none\"` no-op.",
111
+ "rules": {
112
+ "ranges": true
113
+ }
114
+ },
115
+ {
116
+ "id": "el-hyphen-noop",
117
+ "rule": "hyphen",
118
+ "mode": "text",
119
+ "in": "η απόφαση-πλαίσιο και το ελληνο-γαλλικό λεξικό",
120
+ "out": "η απόφαση-πλαίσιο και το ελληνο-γαλλικό λεξικό",
121
+ "note": "hyphen.md §6: all three lists are empty, so the rule is a total no-op. «απόφαση-πλαίσιο» is the appositional compound the Greek source names, and no source defines a closed list of forms whose hyphen must resist a line break."
122
+ },
123
+ {
124
+ "id": "el-quotes-primary",
125
+ "rule": "quotes",
126
+ "mode": "text",
127
+ "in": "Είπε \"καλημέρα\" και έφυγε.",
128
+ "out": "Είπε «καλημέρα» και έφυγε.",
129
+ "note": "quotes.md §3.4: depth 1 takes the primary pair, the Greek guillemets, with innerSpace \"none\" — unlike fr, no space is inserted on the inner edges."
130
+ },
131
+ {
132
+ "id": "el-quotes-nested",
133
+ "rule": "quotes",
134
+ "mode": "text",
135
+ "in": "\"Είπε 'όχι' σε μένα\", σημείωσε.",
136
+ "out": "«Είπε 'όχι' σε μένα», σημείωσε.",
137
+ "note": "quotes.md §6: one stack, inner pair at depth 2 takes the secondary pair U+201C/U+201D. Greek combines guillemets with curly double quotes, which no other v1 locale does. spec 0.5.0: the inner 'όχι' (3 Greek letters, space-flanked) is preserved by the general ambiguous-medial-span veto — el has no elisionIdioms entry for this shape."
138
+ },
139
+ {
140
+ "id": "el-quotes-already-guillemets",
141
+ "rule": "quotes",
142
+ "mode": "text",
143
+ "in": "Το βιβλίο «Ο Ζορμπάς» εκδόθηκε.",
144
+ "out": "Το βιβλίο «Ο Ζορμπάς» εκδόθηκε.",
145
+ "note": "No STRAIGHT marks remain, so there is nothing to resolve. Re-processing corrected Greek text is a no-op."
146
+ },
147
+ {
148
+ "id": "el-quotes-elision-inside-straight-quotes",
149
+ "rule": "quotes",
150
+ "mode": "text",
151
+ "in": "Είπε \"σ' αυτό το βιβλίο\" χθες.",
152
+ "out": "Είπε «σ’ αυτό το βιβλίο» χθες.",
153
+ "note": "quotes.md §6 case 18 and §5.5. Greek elision inside a straight-quoted span. The elision mark in «σ'» is canClose (letter left, space right), but it is an SQ and the opening mark is a DQ, and pass 2 keeps one stack per kind, so they are never paired: the two \" pair with each other and apostrophe converts the elision to U+2019. Before the per-kind split this produced «σ» and destroyed the sentence — damage rather than a miss, and the defect that motivated the split. It was never Greek-specific (en-US, en-GB, ru, fr and de-CH mangled the equivalent input identically); Greek made it unavoidable, because σ', απ', γι' and θ' occur in almost every paragraph."
154
+ },
155
+ {
156
+ "id": "el-apostrophe-elision",
157
+ "rule": "apostrophe",
158
+ "mode": "text",
159
+ "in": "γι' αυτό και απ' την αρχή",
160
+ "out": "γι’ αυτό και απ’ την αρχή",
161
+ "note": "apostrophe.md §3.3 case 3: letter left, space right. Greek elision and apocope take U+2019, per the school grammar for the practice and Unicode §6.2.7 for the code point. U+0384 GREEK TONOS and U+02BC are both refused."
162
+ },
163
+ {
164
+ "id": "el-apostrophe-elision-inside-guillemets",
165
+ "rule": "apostrophe",
166
+ "mode": "text",
167
+ "in": "Είπε «σ' αυτό το βιβλίο» χθες.",
168
+ "out": "Είπε «σ’ αυτό το βιβλίο» χθες.",
169
+ "note": "The same elision inside guillemets that are already curly. Since quotes.md §5.5 this agrees with el-quotes-elision-inside-straight-quotes rather than contradicting it, and the pair is retained precisely so that a regression in the per-kind stack discipline shows up as the two disagreeing again."
170
+ },
171
+ {
172
+ "id": "el-symbols-copyright-and-multiplication",
173
+ "rule": "symbols",
174
+ "mode": "text",
175
+ "in": "Ανάλυση 1920x1080, Copyright (c) 2026",
176
+ "out": "Ανάλυση 1920×1080, Copyright © 2026",
177
+ "note": "symbols.md reads no locale data. Greek adds no branch to it: the refusal to map U+037E to U+003B or U+0387 to U+00B7 is recorded in symbols.md §7.8 as a decided refusal, not implemented as a conversion."
178
+ },
179
+ {
180
+ "id": "el-nbsp-units-not-bound",
181
+ "rule": "nbsp",
182
+ "mode": "text",
183
+ "in": "Η τιμή ανέβηκε 10,5 % φέτος",
184
+ "out": "Η τιμή ανέβηκε 10,5 % φέτος",
185
+ "note": "nbsp.beforeUnits is empty for Greek, so the ordinary space before % is left as U+0020. No Greek normative source states the binding, and a byte-level scan of 13 Greek-language pages found 183 number-plus-unit occurrences with no space of any kind — Greek sets 50% tight, so there is nothing to promote."
186
+ },
187
+ {
188
+ "id": "el-nbsp-no-french-spacing",
189
+ "rule": "nbsp",
190
+ "mode": "text",
191
+ "in": "Καλημέρα! Τι κάνεις;",
192
+ "out": "Καλημέρα! Τι κάνεις;",
193
+ "note": "nbsp.beforePunctuation and narrowBeforePunctuation are both empty. The Greek source denies the French practice by name («πράγμα που συμβαίνει, π.χ., στα γαλλικά»), so nothing is inserted before ! or the U+003B ερωτηματικό. The whole nbsp rule is a no-op for Greek. nbsp.md §6 case 26."
194
+ },
195
+ {
196
+ "id": "el-nbsp-quote-inner-space-none",
197
+ "rule": "nbsp",
198
+ "mode": "text",
199
+ "in": "Είπε «καλημέρα» και έφυγε.",
200
+ "out": "Είπε «καλημέρα» και έφυγε.",
201
+ "note": "nbsp.md §6 case 28. The half of the total-no-op claim that does NOT live in the nbsp object: sub-rule N8 reads quotes.innerSpace, which is \"none\" for Greek, so no inner space is inserted. A locale with all eight nbsp lists empty but a non-none innerSpace would still edit here — fr case 23 is that shape — so this case is what makes \"nbsp is a provable no-op for el\" true rather than merely likely."
202
+ },
203
+ {
204
+ "id": "el-quotes-030-width-drift-no-gate",
205
+ "rule": "quotes",
206
+ "mode": "text",
207
+ "in": "\"Είπε 'όχι' σε μένα\", σημείωσε.",
208
+ "out": "«Είπε 'όχι' σε μένα», σημείωσε.",
209
+ "note": "quotes.md §6 row 16: width drift with no gate intervention — el's secondary pair is WIDE (“ ”), so the inner NARROW pair drifts to WIDE on render and all four marks land on one stack in the gate's re-scan; the certification gate certifies without declining anything, evidence the gate's cost is near zero for the common case. spec 0.5.0: same as el-quotes-nested — 'όχι' is preserved by the general ambiguous-medial-span veto rather than pairing as an ordinary same-width quotation."
210
+ },
211
+ {
212
+ "id": "el-quotes-030-adversarial-interleaved",
213
+ "rule": "quotes",
214
+ "mode": "text",
215
+ "in": "«a“b«c»d”e»",
216
+ "out": "«a“b«c»d”e»",
217
+ "note": "quotes.md §6, ru/el/fr/fr-CA adversarial sweep: already-curly interleaved WIDE-only text. Already correct nesting; no edits, verified idempotent."
218
+ },
219
+ {
220
+ "id": "el-quotes-rock-n-roll",
221
+ "rule": "quotes",
222
+ "mode": "text",
223
+ "in": "rock 'n' roll",
224
+ "out": "rock 'n' roll",
225
+ "note": "spec 0.5.0: el 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.\""
226
+ },
227
+ {
228
+ "id": "el-ranges-none",
229
+ "rule": "ranges",
230
+ "mode": "text",
231
+ "in": "άτομα ηλικίας 25-45 ετών",
232
+ "out": "άτομα ηλικίας 25-45 ετών",
233
+ "rules": {
234
+ "ranges": true
235
+ },
236
+ "note": "spec 0.5.0: el'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."
237
+ }
238
+ ]
239
+ }