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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +109 -0
- data/lib/polytypo/data/README.md +20 -0
- data/lib/polytypo/data/UNICODE +1 -0
- data/lib/polytypo/data/VERSION +1 -0
- data/lib/polytypo/data/fixtures/de-CH.json +501 -0
- data/lib/polytypo/data/fixtures/de-DE.json +547 -0
- data/lib/polytypo/data/fixtures/el.json +239 -0
- data/lib/polytypo/data/fixtures/en-GB.json +1274 -0
- data/lib/polytypo/data/fixtures/en-US.json +1807 -0
- data/lib/polytypo/data/fixtures/fi.json +1306 -0
- data/lib/polytypo/data/fixtures/fr-CA.json +268 -0
- data/lib/polytypo/data/fixtures/fr.json +603 -0
- data/lib/polytypo/data/fixtures/locale-resolution.json +209 -0
- data/lib/polytypo/data/fixtures/ru.json +688 -0
- data/lib/polytypo/data/fixtures/sv.json +1290 -0
- data/lib/polytypo/data/locales/de-CH.json +77 -0
- data/lib/polytypo/data/locales/de-DE.json +76 -0
- data/lib/polytypo/data/locales/el.json +90 -0
- data/lib/polytypo/data/locales/en-GB.json +115 -0
- data/lib/polytypo/data/locales/en-US.json +133 -0
- data/lib/polytypo/data/locales/fi.json +136 -0
- data/lib/polytypo/data/locales/fr-CA.json +78 -0
- data/lib/polytypo/data/locales/fr.json +84 -0
- data/lib/polytypo/data/locales/registry.json +9 -0
- data/lib/polytypo/data/locales/ru.json +112 -0
- data/lib/polytypo/data/locales/sv.json +124 -0
- data/lib/polytypo/data/rules/dashes.md +1238 -0
- data/lib/polytypo/data/rules/order.json +78 -0
- data/lib/polytypo/data/schema/fixtures.schema.json +79 -0
- data/lib/polytypo/data/schema/locale.schema.json +235 -0
- data/lib/polytypo/data/schema/registry.schema.json +29 -0
- data/lib/polytypo/data/schema/resolution.schema.json +50 -0
- data/lib/polytypo/engine/codepoints.rb +24 -0
- data/lib/polytypo/engine/edits.rb +64 -0
- data/lib/polytypo/engine/locale.rb +138 -0
- data/lib/polytypo/engine/pipeline.rb +61 -0
- data/lib/polytypo/engine/registry.rb +47 -0
- data/lib/polytypo/engine/rules/apostrophe.rb +127 -0
- data/lib/polytypo/engine/rules/dash_shared.rb +342 -0
- data/lib/polytypo/engine/rules/dashes.rb +125 -0
- data/lib/polytypo/engine/rules/ellipsis.rb +100 -0
- data/lib/polytypo/engine/rules/hyphen.rb +207 -0
- data/lib/polytypo/engine/rules/nbsp.rb +616 -0
- data/lib/polytypo/engine/rules/quote_ambiguity.rb +241 -0
- data/lib/polytypo/engine/rules/quotes.rb +420 -0
- data/lib/polytypo/engine/rules/ranges.rb +124 -0
- data/lib/polytypo/engine/rules/spaces.rb +232 -0
- data/lib/polytypo/engine/rules/symbols.rb +291 -0
- data/lib/polytypo/engine/rules.rb +19 -0
- data/lib/polytypo/engine/sentinels.rb +23 -0
- data/lib/polytypo/engine/unicode_util.rb +390 -0
- data/lib/polytypo/errors.rb +24 -0
- data/lib/polytypo/modes/html.rb +233 -0
- data/lib/polytypo/modes/markdown.rb +187 -0
- data/lib/polytypo/modes/parse_error.rb +19 -0
- data/lib/polytypo/modes/runner.rb +57 -0
- data/lib/polytypo/modes/spans.rb +132 -0
- data/lib/polytypo/version.rb +5 -0
- data/lib/polytypo.rb +91 -0
- data/polytypo.gemspec +37 -0
- metadata +122 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"spec": "1.0.0",
|
|
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
|
+
"rules": [
|
|
5
|
+
{
|
|
6
|
+
"id": "spaces",
|
|
7
|
+
"order": 10,
|
|
8
|
+
"default": "on",
|
|
9
|
+
"modes": ["text", "html", "markdown"],
|
|
10
|
+
"localeData": [],
|
|
11
|
+
"summary": "Collapse repeated spaces, strip spaces before punctuation, normalize spacing inside brackets."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "ellipsis",
|
|
15
|
+
"order": 20,
|
|
16
|
+
"default": "on",
|
|
17
|
+
"modes": ["text", "html", "markdown"],
|
|
18
|
+
"localeData": ["ellipsis"],
|
|
19
|
+
"summary": "Three dots to U+2026; locale-dependent abbreviated forms after terminal punctuation."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "ranges",
|
|
23
|
+
"order": 25,
|
|
24
|
+
"default": "off",
|
|
25
|
+
"modes": ["text", "html", "markdown"],
|
|
26
|
+
"localeData": ["dash"],
|
|
27
|
+
"summary": "Numeric/date range dash per locale convention (dash.range). Explicit opt-in (spec 0.5.0): a genuine range and a compound label sharing the same digit-hyphen-digit shape (`Figure 5-10`) cannot be told apart without the preceding word, which this project's rules are built never to consult. Off by default; enable with `{ rules: { ranges: true } }`."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "dashes",
|
|
31
|
+
"order": 30,
|
|
32
|
+
"default": "on",
|
|
33
|
+
"modes": ["text", "html", "markdown"],
|
|
34
|
+
"localeData": ["dash"],
|
|
35
|
+
"summary": "Parenthetical dash per locale convention (dash.parenthetical). Never reinterprets a digit-flanked hyphen as a parenthetical dash — that shape belongs to `ranges` alone, whether or not `ranges` is enabled (spec 0.5.0; range detection moved out of this rule)."
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "hyphen",
|
|
39
|
+
"order": 35,
|
|
40
|
+
"default": "on",
|
|
41
|
+
"modes": ["text", "html", "markdown"],
|
|
42
|
+
"localeData": ["hyphen"],
|
|
43
|
+
"summary": "Bind morphological hyphen forms with U+2011 (non-breaking hyphen) so they cannot be broken across lines. Runs after `dashes`, which has already declined every letter-adjacent hyphen. A no-op for locales whose hyphen lists are empty."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "quotes",
|
|
47
|
+
"order": 40,
|
|
48
|
+
"default": "on",
|
|
49
|
+
"modes": ["text", "html", "markdown"],
|
|
50
|
+
"localeData": ["quotes"],
|
|
51
|
+
"summary": "Straight quotes to locale primary/secondary pairs with nesting resolution. Runs before `apostrophe` so that a straight apostrophe is still available as quote evidence. Declines to pair an ambiguous medial short single-quoted span (spec 0.5.0) unless a cited `quotes.elisionIdioms` entry matches."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "apostrophe",
|
|
55
|
+
"order": 50,
|
|
56
|
+
"default": "on",
|
|
57
|
+
"modes": ["text", "html", "markdown"],
|
|
58
|
+
"localeData": ["quotes"],
|
|
59
|
+
"summary": "Remaining straight apostrophes to U+2019 without corrupting contractions. As of spec 0.5.0, also reads `quotes.elisionIdioms` (indirectly, via the shared ambiguity predicate) to keep a straight-ASCII mark `quotes` deliberately preserved from being independently curled by this rule's own structural case ladder."
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "symbols",
|
|
63
|
+
"order": 60,
|
|
64
|
+
"default": "on",
|
|
65
|
+
"modes": ["text", "html", "markdown"],
|
|
66
|
+
"localeData": [],
|
|
67
|
+
"summary": "(c)/(r)/(tm) to the corresponding signs; multiplication sign between numerals."
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "nbsp",
|
|
71
|
+
"order": 70,
|
|
72
|
+
"default": "on",
|
|
73
|
+
"modes": ["text", "html", "markdown"],
|
|
74
|
+
"localeData": ["nbsp", "quotes"],
|
|
75
|
+
"summary": "Insert no-break and narrow no-break spaces per locale. Runs last so that every other rule has already settled the surrounding characters."
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://polytypo.org/schema/fixtures.schema.json",
|
|
4
|
+
"title": "polytypo conformance fixtures",
|
|
5
|
+
"description": "Executable form of the rule semantics. Every case is also an idempotency case: a runner must additionally assert transform(out) == out.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["spec", "locale", "cases"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"spec": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "The current global spec contract version. Must equal spec/VERSION exactly — not an authoring-time stamp and not a per-case introduction version. scripts/validate-spec.mjs enforces this for every fixture root."
|
|
13
|
+
},
|
|
14
|
+
"locale": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" },
|
|
15
|
+
"cases": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"minItems": 1,
|
|
18
|
+
"items": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"additionalProperties": false,
|
|
21
|
+
"required": ["id", "rule", "mode", "in"],
|
|
22
|
+
"allOf": [
|
|
23
|
+
{
|
|
24
|
+
"if": { "properties": { "mode": { "const": "markdown" } }, "required": ["mode"] },
|
|
25
|
+
"then": { "required": ["dialect"] },
|
|
26
|
+
"else": { "not": { "required": ["dialect"] } }
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"oneOf": [{ "required": ["out"] }, { "required": ["throws"] }],
|
|
30
|
+
"properties": {
|
|
31
|
+
"id": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
|
34
|
+
"description": "Stable, unique within the file. Convention: <locale>-<rule>-<what>."
|
|
35
|
+
},
|
|
36
|
+
"rule": {
|
|
37
|
+
"enum": [
|
|
38
|
+
"spaces",
|
|
39
|
+
"ellipsis",
|
|
40
|
+
"ranges",
|
|
41
|
+
"dashes",
|
|
42
|
+
"hyphen",
|
|
43
|
+
"quotes",
|
|
44
|
+
"apostrophe",
|
|
45
|
+
"symbols",
|
|
46
|
+
"nbsp"
|
|
47
|
+
],
|
|
48
|
+
"description": "The rule under test. Required so a runtime can report partial conformance honestly."
|
|
49
|
+
},
|
|
50
|
+
"mode": { "enum": ["text", "html", "markdown"] },
|
|
51
|
+
"dialect": {
|
|
52
|
+
"enum": ["commonmark", "mdx"],
|
|
53
|
+
"description": "Required when mode is \"markdown\" — spec/rules/modes.md §3.7.1 forbids detection, so a fixture must name the dialect exactly as a caller would. Meaningless for the other modes."
|
|
54
|
+
},
|
|
55
|
+
"in": { "type": "string" },
|
|
56
|
+
"out": { "type": "string" },
|
|
57
|
+
"throws": {
|
|
58
|
+
"enum": [
|
|
59
|
+
"POLYTYPO_UNKNOWN_LOCALE",
|
|
60
|
+
"POLYTYPO_INVALID_MODE",
|
|
61
|
+
"POLYTYPO_INVALID_DIALECT",
|
|
62
|
+
"POLYTYPO_UNKNOWN_RULE",
|
|
63
|
+
"POLYTYPO_MALFORMED_LOCALE_DATA",
|
|
64
|
+
"POLYTYPO_RULE_CONTRACT",
|
|
65
|
+
"POLYTYPO_MALFORMED_INPUT"
|
|
66
|
+
],
|
|
67
|
+
"description": "Expected error code instead of an output string. Messages are not part of the contract; codes are."
|
|
68
|
+
},
|
|
69
|
+
"note": { "type": "string" },
|
|
70
|
+
"rules": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"additionalProperties": { "type": "boolean" },
|
|
73
|
+
"description": "Optional rule overrides for this case, same shape as the public `rules` option."
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://polytypo.org/schema/locale.schema.json",
|
|
4
|
+
"title": "polytypo locale data",
|
|
5
|
+
"description": "Declarative typographic conventions for one locale. Literal strings, literal code points, string lists and enum values only — no patterns, no priorities, no expressions (docs/ARCHITECTURE.md section 5.1).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["locale", "name", "quotes", "dash", "ellipsis", "hyphen", "nbsp", "sources"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"locale": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"pattern": "^[a-z]{2}(-[A-Z]{2})?$",
|
|
13
|
+
"description": "BCP-47 language or language-region tag. Must equal the file name."
|
|
14
|
+
},
|
|
15
|
+
"name": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "English name of the locale, for generated documentation."
|
|
18
|
+
},
|
|
19
|
+
"quotes": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"required": ["primary", "secondary", "elisionIdioms"],
|
|
23
|
+
"properties": {
|
|
24
|
+
"primary": { "$ref": "#/$defs/quotePair" },
|
|
25
|
+
"secondary": { "$ref": "#/$defs/quotePair" },
|
|
26
|
+
"elisionIdioms": {
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": { "$ref": "#/$defs/elisionIdiom" },
|
|
29
|
+
"uniqueItems": true,
|
|
30
|
+
"description": "Literal, closed-set elision idioms consulted only to decline pairing two NARROW quote marks as a quotation when the full context — the word immediately before the opening mark and the word immediately after the closing mark, not merely the elided content between them — matches (quotes.md 3.2's listed elision veto), e.g. English { left: \"rock\", elided: \"n\", right: \"roll\" } for \"rock 'n' roll\". Empty is the default and means no verified idiom of this shape — a missing citation is never a licence to guess. Matching the elided content alone (a bare word list) is insufficient and is not this field's shape: it cannot distinguish the idiom from an arbitrary quoted letter, e.g. \"The letter 'n' is common.\", which is exactly the false positive this three-part contract exists to avoid."
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dash": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"required": ["parenthetical", "range"],
|
|
38
|
+
"properties": {
|
|
39
|
+
"parenthetical": {
|
|
40
|
+
"enum": ["em-tight", "em-spaced", "en-tight", "en-spaced", "none"],
|
|
41
|
+
"description": "Dash used for a parenthetical break. `-tight` means no surrounding spaces, `-spaced` means a regular space on each side (the leading one may later be made non-breaking by the nbsp rule). `none` means the locale has no verified convention and the rule must not substitute anything."
|
|
42
|
+
},
|
|
43
|
+
"range": {
|
|
44
|
+
"enum": ["em-tight", "em-spaced", "en-tight", "en-spaced", "none"],
|
|
45
|
+
"description": "Dash used between numbers and dates. Russian sets an em dash here. `none` means the locale has no verified convention and the rule must not substitute anything — a missing citation is never a licence to guess."
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"ellipsis": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"additionalProperties": false,
|
|
52
|
+
"required": ["abbreviatedAfterTerminal"],
|
|
53
|
+
"properties": {
|
|
54
|
+
"abbreviatedAfterTerminal": {
|
|
55
|
+
"type": "boolean",
|
|
56
|
+
"description": "When true, an ellipsis directly after `!` or `?` is written with two dots (`?..`) rather than three. Russian convention."
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"hyphen": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"additionalProperties": false,
|
|
63
|
+
"required": ["prefixes", "suffixes", "compounds"],
|
|
64
|
+
"description": "Morphological forms whose hyphen must not break across lines. Empty arrays are the normal case — only a few languages have a normative list.",
|
|
65
|
+
"properties": {
|
|
66
|
+
"prefixes": {
|
|
67
|
+
"$ref": "#/$defs/wordList",
|
|
68
|
+
"description": "Word-initial forms written with a trailing hyphen, e.g. Russian \"кое-\"."
|
|
69
|
+
},
|
|
70
|
+
"suffixes": {
|
|
71
|
+
"$ref": "#/$defs/wordList",
|
|
72
|
+
"description": "Word-final forms written with a leading hyphen, e.g. Russian \"-таки\"."
|
|
73
|
+
},
|
|
74
|
+
"compounds": {
|
|
75
|
+
"$ref": "#/$defs/wordList",
|
|
76
|
+
"description": "Whole hyphenated forms, written with U+002D, e.g. Russian \"из-под\"."
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"nbsp": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"additionalProperties": false,
|
|
83
|
+
"required": [
|
|
84
|
+
"beforePunctuation",
|
|
85
|
+
"narrowBeforePunctuation",
|
|
86
|
+
"afterShortWords",
|
|
87
|
+
"abbreviations",
|
|
88
|
+
"beforeUnits",
|
|
89
|
+
"beforeNumber",
|
|
90
|
+
"beforeWord",
|
|
91
|
+
"afterSymbols",
|
|
92
|
+
"initialBinding"
|
|
93
|
+
],
|
|
94
|
+
"properties": {
|
|
95
|
+
"beforePunctuation": {
|
|
96
|
+
"$ref": "#/$defs/charList",
|
|
97
|
+
"description": "Characters that take U+00A0 before them. Q-P (quotes.md 2.1, enforced by scripts/validate-spec.mjs): every entry must be a member of the quotes rule's CLOSEISH."
|
|
98
|
+
},
|
|
99
|
+
"narrowBeforePunctuation": {
|
|
100
|
+
"$ref": "#/$defs/charList",
|
|
101
|
+
"description": "Characters that take U+202F before them. Q-P (quotes.md 2.1, enforced by scripts/validate-spec.mjs): every entry must be a member of the quotes rule's CLOSEISH."
|
|
102
|
+
},
|
|
103
|
+
"afterShortWords": {
|
|
104
|
+
"type": "array",
|
|
105
|
+
"items": { "type": "string", "minLength": 1 },
|
|
106
|
+
"uniqueItems": true,
|
|
107
|
+
"description": "Lowercase words that must not be left at the end of a line; the space after them becomes U+00A0. Matching is case-insensitive only for the first character."
|
|
108
|
+
},
|
|
109
|
+
"abbreviations": {
|
|
110
|
+
"type": "array",
|
|
111
|
+
"items": { "type": "string", "minLength": 2 },
|
|
112
|
+
"uniqueItems": true,
|
|
113
|
+
"description": "Literal multi-token abbreviations written with regular U+0020; every internal space becomes U+00A0. Example: \"z. B.\"."
|
|
114
|
+
},
|
|
115
|
+
"beforeUnits": {
|
|
116
|
+
"type": "array",
|
|
117
|
+
"items": { "type": "string", "minLength": 1 },
|
|
118
|
+
"uniqueItems": true,
|
|
119
|
+
"description": "Units and signs that bind to a preceding number with U+00A0. Example: \"%\", \"km\", \"°C\"."
|
|
120
|
+
},
|
|
121
|
+
"beforeNumber": {
|
|
122
|
+
"type": "array",
|
|
123
|
+
"items": { "type": "string", "minLength": 2 },
|
|
124
|
+
"uniqueItems": true,
|
|
125
|
+
"description": "Abbreviations that bind to a following number with U+00A0. Example: \"Nr.\", \"S.\", \"art.\"."
|
|
126
|
+
},
|
|
127
|
+
"beforeWord": {
|
|
128
|
+
"type": "array",
|
|
129
|
+
"items": { "type": "string", "minLength": 2 },
|
|
130
|
+
"uniqueItems": true,
|
|
131
|
+
"description": "Abbreviations that bind to a following word with U+00A0. Example: \"г.\", \"ул.\", \"M.\", \"Mme\". Riskier than beforeNumber — list only forms whose binding is normative."
|
|
132
|
+
},
|
|
133
|
+
"afterSymbols": {
|
|
134
|
+
"type": "array",
|
|
135
|
+
"items": { "type": "string", "minLength": 1 },
|
|
136
|
+
"uniqueItems": true,
|
|
137
|
+
"description": "Symbols that bind to a following number with U+00A0. Example: \"§\", \"№\"."
|
|
138
|
+
},
|
|
139
|
+
"initialBinding": {
|
|
140
|
+
"enum": ["none", "chain", "single"],
|
|
141
|
+
"description": "N7's mode (spec 0.6.0, replaces the boolean bindInitials). \"none\": N7 disabled. \"chain\": binds only a confirmed sequence of two or more adjacent initials (Chicago's own \"two or more initials\" wording) — a lone initial next to an ordinary capitalized word is left alone. \"single\": additionally binds one lone initial to a following word (Jacques André's citation), which cannot structurally distinguish every genuine name from a sentence-boundary collision."
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
"sources": {
|
|
146
|
+
"type": "array",
|
|
147
|
+
"minItems": 1,
|
|
148
|
+
"items": {
|
|
149
|
+
"type": "object",
|
|
150
|
+
"additionalProperties": false,
|
|
151
|
+
"required": ["rule", "cite"],
|
|
152
|
+
"properties": {
|
|
153
|
+
"rule": {
|
|
154
|
+
"enum": [
|
|
155
|
+
"quotes",
|
|
156
|
+
"dashes",
|
|
157
|
+
"ranges",
|
|
158
|
+
"ellipsis",
|
|
159
|
+
"hyphen",
|
|
160
|
+
"nbsp",
|
|
161
|
+
"spaces",
|
|
162
|
+
"apostrophe",
|
|
163
|
+
"symbols"
|
|
164
|
+
],
|
|
165
|
+
"description": "Which rule the citation is normative for. Values are rule ids from spec/rules/order.json — note the rule is `dashes`/`ranges` while the locale-data field both read is `dash`."
|
|
166
|
+
},
|
|
167
|
+
"cite": {
|
|
168
|
+
"type": "string",
|
|
169
|
+
"minLength": 8,
|
|
170
|
+
"description": "Normative authority, named precisely enough to be looked up offline."
|
|
171
|
+
},
|
|
172
|
+
"url": { "type": "string", "format": "uri" },
|
|
173
|
+
"note": { "type": "string" }
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
"description": "Mandatory. Every locale-driven rule with non-default data must be covered by at least one citation (docs/PLAN.md section 6.1)."
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"$defs": {
|
|
180
|
+
"quotePair": {
|
|
181
|
+
"type": "object",
|
|
182
|
+
"additionalProperties": false,
|
|
183
|
+
"required": ["open", "close", "innerSpace"],
|
|
184
|
+
"properties": {
|
|
185
|
+
"open": { "$ref": "#/$defs/singleChar" },
|
|
186
|
+
"close": { "$ref": "#/$defs/singleChar" },
|
|
187
|
+
"innerSpace": {
|
|
188
|
+
"enum": ["none", "nbsp", "narrow-nbsp"],
|
|
189
|
+
"description": "Space inserted between the quotation mark and the quoted text, as in French guillemets."
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
"description": "Q-W (quotes.md 2.1, enforced by scripts/validate-spec.mjs): open and close must share a WIDE/NARROW visual width. Q-A: if innerSpace is not \"none\", neither open nor close may be U+2019."
|
|
193
|
+
},
|
|
194
|
+
"singleChar": {
|
|
195
|
+
"type": "string",
|
|
196
|
+
"minLength": 1,
|
|
197
|
+
"maxLength": 2,
|
|
198
|
+
"not": { "enum": ["\"", "'"] },
|
|
199
|
+
"description": "Exactly one Unicode code point, written as the literal character. maxLength is 2 only because a JSON validator counts UTF-16 units; the one-code-point rule is enforced by scripts/validate-spec.mjs, which is the portable check. U+0022 and U+0027 are forbidden: the quotes rule reasons about straight marks as input and would not terminate if a locale declared one as output."
|
|
200
|
+
},
|
|
201
|
+
"wordList": {
|
|
202
|
+
"type": "array",
|
|
203
|
+
"items": { "type": "string", "minLength": 2 },
|
|
204
|
+
"uniqueItems": true
|
|
205
|
+
},
|
|
206
|
+
"charList": {
|
|
207
|
+
"type": "array",
|
|
208
|
+
"items": { "$ref": "#/$defs/singleChar" },
|
|
209
|
+
"uniqueItems": true
|
|
210
|
+
},
|
|
211
|
+
"elisionIdiom": {
|
|
212
|
+
"type": "object",
|
|
213
|
+
"additionalProperties": false,
|
|
214
|
+
"required": ["left", "elided", "right"],
|
|
215
|
+
"properties": {
|
|
216
|
+
"left": {
|
|
217
|
+
"type": "string",
|
|
218
|
+
"minLength": 1,
|
|
219
|
+
"description": "Literal word immediately before the opening NARROW mark, separated from it by exactly one permitted inline-space code point. Matched as a whole word (quotes.md 3.2), first code point case-insensitive, the rest exact. Every code point must be LETTER (quotes.md 3.2's Word definition) — not because the engine's literal matcher would fail to match a digit or punctuation code point (it would match those exact bytes wherever they occur), but because an entry outside LETTER would make runtime behaviour exceed what the normative Word definition authorises. JSON Schema cannot express a Unicode-category constraint, so this is enforced by scripts/validate-spec.mjs and scripts/gen-locales.mjs against the same pinned table src/engine/unicode.ts imports, not by this schema."
|
|
220
|
+
},
|
|
221
|
+
"elided": {
|
|
222
|
+
"type": "string",
|
|
223
|
+
"minLength": 1,
|
|
224
|
+
"description": "Literal content between the two NARROW marks, matched exactly, code point for code point, no case leniency. Not constrained to LETTER: the match is a literal equality test, not a Word-boundary walk."
|
|
225
|
+
},
|
|
226
|
+
"right": {
|
|
227
|
+
"type": "string",
|
|
228
|
+
"minLength": 1,
|
|
229
|
+
"description": "Literal word immediately after the closing NARROW mark, separated from it by exactly one permitted inline-space code point. Matched as a whole word, first code point case-insensitive, the rest exact. Same LETTER-only constraint as \"left\", enforced the same way."
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
"description": "A closed-set elision idiom (quotes.md 3.2's listed elision veto). All three fields are required and non-empty: matching only `elided` cannot distinguish an idiom from an arbitrary quoted word and is not a supported shape."
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://polytypo.org/schema/registry.schema.json",
|
|
4
|
+
"title": "polytypo locale registry",
|
|
5
|
+
"description": "The only data locale resolution consumes (spec/rules/locale-resolution.md section 2). The three registry invariants — alias targets exist, aliases never shadow a locale, aliases are never transitive — are relational and are enforced by scripts/validate-spec.mjs, not expressible here.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["locales", "aliases"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"spec": { "type": "string" },
|
|
11
|
+
"$comment": { "type": "string" },
|
|
12
|
+
"locales": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"minItems": 1,
|
|
15
|
+
"uniqueItems": true,
|
|
16
|
+
"items": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"pattern": "^[a-z]{2}(-[A-Z]{2})?$",
|
|
19
|
+
"description": "Concrete locale identifier with a file at spec/locales/<id>.json."
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"aliases": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"description": "Maps a tag to a member of `locales`. A value is a final answer, never another alias key.",
|
|
25
|
+
"propertyNames": { "pattern": "^[a-z]{2}(-[A-Z]{2})?$" },
|
|
26
|
+
"additionalProperties": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://polytypo.org/schema/resolution.schema.json",
|
|
4
|
+
"title": "polytypo locale-resolution fixtures",
|
|
5
|
+
"description": "Conformance cases for spec/rules/locale-resolution.md. Kept separate from spec/schema/fixtures.schema.json because a resolution case has no rule, no mode and no text — it maps a requested tag to a resolved locale or to an error code.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["spec", "cases"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"spec": { "type": "string" },
|
|
11
|
+
"cases": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"minItems": 1,
|
|
14
|
+
"items": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": false,
|
|
17
|
+
"required": ["id"],
|
|
18
|
+
"oneOf": [{ "required": ["resolves"] }, { "required": ["throws"] }],
|
|
19
|
+
"allOf": [
|
|
20
|
+
{
|
|
21
|
+
"if": { "properties": { "tagAbsent": { "const": true } }, "required": ["tagAbsent"] },
|
|
22
|
+
"then": { "not": { "required": ["tag"] } },
|
|
23
|
+
"else": { "required": ["tag"] }
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"properties": {
|
|
27
|
+
"id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
28
|
+
"tag": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "The tag as passed by the caller, verbatim — including any casing or separator the resolver is expected to accept or reject."
|
|
31
|
+
},
|
|
32
|
+
"tagAbsent": {
|
|
33
|
+
"const": true,
|
|
34
|
+
"description": "The caller supplied no locale at all, or a null one — the case spec/rules/locale-resolution.md §3.1 requires to throw. A closed flag rather than a widened `tag` type, so every runtime reads one intent instead of reinterpreting null, undefined and missing keys in its own way."
|
|
35
|
+
},
|
|
36
|
+
"resolves": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"pattern": "^[a-z]{2}(-[A-Z]{2})?$",
|
|
39
|
+
"description": "Canonical locale the tag must resolve to."
|
|
40
|
+
},
|
|
41
|
+
"throws": {
|
|
42
|
+
"enum": ["POLYTYPO_UNKNOWN_LOCALE"],
|
|
43
|
+
"description": "Resolution never falls back to English; an unresolvable tag throws."
|
|
44
|
+
},
|
|
45
|
+
"note": { "type": "string" }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Polytypo
|
|
4
|
+
module Engine
|
|
5
|
+
# String <-> code-point array conversion (docs/ARCHITECTURE.md section 4.2: rules index an
|
|
6
|
+
# explicit code-point array, never a native string). Ruby strings are already sequences of
|
|
7
|
+
# Unicode code points for a UTF-8-encoded String -- this module exists for cross-runtime
|
|
8
|
+
# consistency and because every rule below is written against "index i of the code-point
|
|
9
|
+
# array," not "index i of the string."
|
|
10
|
+
module Codepoints
|
|
11
|
+
def self.to_codepoints(str)
|
|
12
|
+
str.codepoints
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from_codepoints(cp)
|
|
16
|
+
cp.pack("U*")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.valid_codepoint?(value)
|
|
20
|
+
value.is_a?(Integer) && value >= 0 && value <= 0x10FFFF
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codepoints"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module Polytypo
|
|
7
|
+
module Engine
|
|
8
|
+
# One replacement a rule proposes: replace cp[start...end] with replacement. Indices address
|
|
9
|
+
# the code-point array, never a native string (docs/ARCHITECTURE.md section 4.2).
|
|
10
|
+
Edit = Struct.new(:start, :end, :replacement, :rule_id)
|
|
11
|
+
|
|
12
|
+
module Edits
|
|
13
|
+
# Enforces the rule contract rather than trusting it (rules are community-contributed):
|
|
14
|
+
# edits must be in bounds, ascending, non-overlapping, and made of real code points.
|
|
15
|
+
# rule_id is nil when validating a caller-agnostic batch.
|
|
16
|
+
def self.validate_edits(edits, length, rule_id = nil)
|
|
17
|
+
previous_end = 0
|
|
18
|
+
edits.each_with_index do |edit, i|
|
|
19
|
+
where = rule_id.nil? ? "edit #{i}" : "rule #{rule_id.inspect} edit #{i}"
|
|
20
|
+
|
|
21
|
+
if edit.start.negative? || edit.end > length
|
|
22
|
+
reject("#{where} is out of bounds (#{edit.start}, #{edit.end}) for length #{length}")
|
|
23
|
+
end
|
|
24
|
+
if edit.end < edit.start
|
|
25
|
+
reject("#{where} has end #{edit.end} before start #{edit.start}")
|
|
26
|
+
end
|
|
27
|
+
if edit.start < previous_end
|
|
28
|
+
reject("#{where} starts at #{edit.start}, which overlaps or precedes the previous edit")
|
|
29
|
+
end
|
|
30
|
+
if !rule_id.nil? && edit.rule_id != rule_id
|
|
31
|
+
reject("#{where} is tagged #{edit.rule_id.inspect} but was produced by rule #{rule_id.inspect}")
|
|
32
|
+
end
|
|
33
|
+
edit.replacement.each do |value|
|
|
34
|
+
unless Codepoints.valid_codepoint?(value)
|
|
35
|
+
reject("#{where} contains an invalid code point (#{value})")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
previous_end = edit.end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Applies validated edits left to right, producing a new code-point array.
|
|
43
|
+
def self.apply_edits(cp, edits, rule_id = nil)
|
|
44
|
+
validate_edits(edits, cp.length, rule_id)
|
|
45
|
+
return cp.dup if edits.empty?
|
|
46
|
+
|
|
47
|
+
out = []
|
|
48
|
+
cursor = 0
|
|
49
|
+
edits.each do |edit|
|
|
50
|
+
out.concat(cp[cursor...edit.start])
|
|
51
|
+
out.concat(edit.replacement)
|
|
52
|
+
cursor = edit.end
|
|
53
|
+
end
|
|
54
|
+
out.concat(cp[cursor..])
|
|
55
|
+
out
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.reject(message)
|
|
59
|
+
raise Polytypo::Error.new(Polytypo::CODE_RULE_CONTRACT, message)
|
|
60
|
+
end
|
|
61
|
+
private_class_method :reject
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|