@heroiclands/package-build 25.1.0 → 25.1.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.1.1
4
+
5
+ **Language conversion**
6
+
7
+ - Scripts can opt into conventional spellings for unwritten vowels and shared signs without hidden recovery marks; exact conversion remains the default.
8
+ - Signs can vary before consonants or at word and line boundaries, and explicit word dividers can become spaces.
9
+ - Accent settings can explicitly allow or forbid diacritics.
10
+
11
+ **Spelling checks**
12
+
13
+ - English translations in name registers no longer trigger spelling errors for the invented language.
14
+
3
15
  ## 25.1.0
4
16
 
5
17
  **Writing and conversion**
package/docs/api.md CHANGED
@@ -2799,6 +2799,13 @@ expose their schema `key`.
2799
2799
  | `encodeText` | `encodeText(encoding, text, {font, verse})` | string | encoding exactly by rows and inline codes |
2800
2800
  | `decodeText` | `decodeText(encoding, text, {font})` | string | decoding by longest output matches |
2801
2801
 
2802
+ Encoding rules default to exact, uniquely decodable outputs. A note may state
2803
+ `rules.roundTrip: canonical` to permit aliases and empty code sequences;
2804
+ `decodeText` then returns the authored conventional reading without restoring
2805
+ erased Roman characters. Contextual rules accept `before: vowel|consonant`
2806
+ and `at: start|end|line-start`; supplied conditions combine with AND. Accent
2807
+ permission blocks accept explicit Boolean values. See [Encoding text](encoding-core.md).
2808
+
2802
2809
  ### `engine.encodingLanguage`
2803
2810
 
2804
2811
  Pure language-aware conversion takes a compiled encoding and optional plain
@@ -48,18 +48,75 @@ second behaviour.
48
48
  | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
49
  | `normalization` | Required `NFC`; input is normalized before matching, with original source offsets retained. |
50
50
  | `matching` | Required `longest-first`. |
51
+ | `roundTrip` | Optional `exact` (default) or `canonical`; canonical permits authored aliases and empty code sequences. |
52
+ | `word-separators` | Optional list of source word dividers that emit shared word spaces after contextual alternatives. |
51
53
  | `case` | `{single-letter: preserve, consonant-group: lowercase-or-initial-uppercase, other-group-cases: forbidden}`; single letters retain authored case, and groups use lowercase or an initial capital. |
52
54
  | `stress` | `{modifier, position: after-stressed-vowel, printing: false, effect: stress-only, changes-sound: false, changes-length: false, vowel-group-position: first-vowel}`; `modifier` names a font code and acute mappings append it to the unchanged vowel. |
53
55
  | `vowel-groups` | `{reading: established, allow-splitting: false, unrecognized-adjacent-vowels: separate, groups: [...]}`; the explicit group list determines where an acute belongs on its first vowel. |
54
- | `diaeresis` | `{allowed: false}` forbids diaeresis mappings. |
55
- | `grave-accent` | `{allowed: false}` forbids grave-accent mappings. |
56
- | `contextual-mappings` | A list of `{match, before: vowel, emit}`; the contextual mapping precedes an unconditional mapping and `emit` names one code or a sequence. |
56
+ | `diaeresis` | `{allowed: boolean}` permits or forbids explicitly mapped diaeresis characters. |
57
+ | `grave-accent` | `{allowed: boolean}` permits or forbids explicitly mapped grave-accent characters. |
58
+ | `contextual-mappings` | A list of `{match, emit, before?, at?}`; following-letter or source-position conditions precede unconditional mappings. |
57
59
  | `y` | `{kind: consonant, sound: "y in yes", vowel-reading: false}`; y cannot also appear in `data.vowels`. |
58
60
 
61
+ The accent blocks accept an explicit Boolean `allowed`. False forbids that
62
+ accent; true permits it only where the character is accepted and mapped.
63
+ Omitting the block also permits such explicit mappings. Neither choice invents
64
+ an accent pronunciation or adds a character to the inventory.
65
+
66
+ `rules.roundTrip` is optional and defaults to `exact`. Exact mappings must be
67
+ uniquely decodable across adjacent mappings and spaces; ambiguous outputs and
68
+ empty code sequences are refused. This remains the default for Aelwythi.
69
+
70
+ An encoding whose orthography omits distinctions may explicitly state
71
+ `roundTrip: canonical`. Its mappings and conditional emissions may use `[]`
72
+ for an inherent vowel or deleted boundary marker, and different Roman sources
73
+ may name the same output. Decoding returns a conventional spelling. For an output it prefers the first
74
+ authored alias whose following-letter condition matches the decoded suffix,
75
+ with conditional rows before general mappings and longest complete native
76
+ matches first. When none applies, the first authored reading is retained:
77
+ an inherent vowel or deleted marker may have hidden the original condition. It does not invent characters for
78
+ empty outputs or store invisible metadata to restore the original spelling.
79
+ A language's authoritative vocabulary can recover fuller word spellings.
80
+ Shared word spaces remain spaces in both modes.
81
+
82
+ Contextual entries use `{match, emit, before?, at?}` and require at least one
83
+ condition. `before` is `vowel` or `consonant`; `at` is `start`, `end` or
84
+ `line-start`. `start` and `end` refer to word boundaries. A consonant is a
85
+ letter outside the declared `data.vowels`. Line start follows a newline or
86
+ verse slash and permits preceding indentation carets. Multiple conditions
87
+ combine with AND. Entries for the same source may use different conditions;
88
+ repeating the same source and condition tuple is an error. Equal-length
89
+ contextual matches retain authored order.
90
+
91
+ ```yaml
92
+ rules:
93
+ normalization: NFC
94
+ matching: longest-first
95
+ roundTrip: canonical
96
+ contextual-mappings:
97
+ - { match: e, before: consonant, emit: [] }
98
+ - { match: e, at: end, emit: [] }
99
+ ```
100
+
101
+ Conditions govern forward matching. Inverse following-letter choices use the
102
+ visible decoded suffix; canonical mode keeps its authored fallback when that
103
+ suffix has lost an inherent vowel or marker. Position conditions govern only
104
+ forward matching. No mode invents deleted markers. Exact decoding retains
105
+ strict following-letter checks.
106
+
107
+ `rules.word-separators` lists source word dividers, such as `['-']`, that emit
108
+ the shared ASCII word space. Contextual rules take priority, so a canonical
109
+ note can emit `[]` for an edge hyphen and a word space for an internal hyphen.
110
+ These sources cannot also be ordinary mappings, replace shared punctuation,
111
+ or contain whitespace, literal markers or structural controls. Separator
112
+ sources participate in inferred `accepts`; no font SPACE code is invented.
113
+ Since a divider and an ordinary space share output, such a note selects
114
+ canonical round trips. Decoding keeps the shared space as a space.
115
+
59
116
  A contextual match cannot contain whitespace, literal markers or forbidden
60
- accents. Mapping outputs must be uniquely decodable across arbitrary adjacent
61
- mappings and spaces; ambiguous concatenations are refused. The compiler does
62
- not infer linguistic vowel groups or silently add missing letter mappings.
117
+ accents. Both modes enforce the same accepted characters, normalization,
118
+ case, stress, accent and literal-control rules. The compiler does not infer
119
+ linguistic vowel groups or silently add missing letter mappings.
63
120
 
64
121
  Lint checks every authored word, variant and registered name for a language
65
122
  with an encoding document, including unused entries. It also checks actual
@@ -110,7 +167,8 @@ fixtures, not an authored input format.
110
167
  `EncodingError` and returns no partial text. `offset` is a zero-based UTF-16
111
168
  offset into the input, suitable for composing with a Markdown offset map.
112
169
  `line` and `column` are one-based Unicode code-point positions; schema errors
113
- expose their `key`. Decoding resolves the complete unambiguous output sequence.
170
+ expose their `key`. Exact decoding resolves the complete unambiguous output sequence. Canonical
171
+ decoding selects the authored conventional reading.
114
172
 
115
173
  `encodePieces` uses the same rules and returns `{from, to, start, end, row}`
116
174
  records. `start` and exclusive `end` delimit original UTF-16 input, including
@@ -40,6 +40,13 @@ Language identification alone retains Roman text. `enc=` selects an encoding
40
40
  document, which infers its language and font. Unknown source characters are
41
41
  errors; a face drawing a character does not make it a spelling mapping.
42
42
 
43
+ An encoding uses exact round trips by default. If its script omits Roman
44
+ distinctions, its note can explicitly select `rules.roundTrip: canonical`.
45
+ Decoding then gives an authored conventional spelling; authoritative lexicon
46
+ entries can recover fuller word forms. The encoder adds no invisible metadata
47
+ to restore erased spelling. See the [canonical encoding contract](../encoding-core.md#executable-rules)
48
+ for aliases, inherent vowels and shared word separators.
49
+
43
50
  ## Language and native presentation
44
51
 
45
52
  `enc=` names an encoding document, with `doc` as its default Address type:
@@ -8084,69 +8084,72 @@ Content prepared to be played — a situation with its cast, places, and possibl
8084
8084
  - settingguide: A culture's guide to the setting, which names its culture in `data.culture`. It orients a reader to a setting or region, bringing its places, peoples, institutions, and daily life into one usable frame with links to detailed notes.
8085
8085
  - post: A blog post, which states the day it was published in `data.date` and may name its writer in `data.author`. A site whose `site.feed` names a section lists its posts there, newest first, and feeds them as Atom.
8086
8086
 
8087
- | `data` property | Values | Description |
8088
- | ------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
8089
- | `language` | a configured language alias | The configured language alias an encoding document writes. |
8090
- | `font` | an installed font slug | The encoding's installed font, which supplies all glyph codes. |
8091
- | `accepts` | a list of characters or ranges | Accepted Roman letters and additions to standard punctuation. |
8092
- | `vowels` | a string | The letters a contextual vowel match reads as vowels. |
8093
- | `mappings` | Roman strings to glyph codes | Roman source strings mapped to codes published by the selected font. |
8094
- | `rules` | the closed encoding rules vocabulary | Executable normalization, matching, case, stress, vowel groups, accents and contextual mappings. |
8095
- | `rules.normalization` | "NFC" | Required value: "NFC". |
8096
- | `rules.matching` | "longest-first" | Required value: "longest-first". |
8097
- | `rules.case` | a map | Case handling for letters and consonant groups. |
8098
- | `rules.case.single-letter` | "preserve" | Required value: "preserve". |
8099
- | `rules.case.consonant-group` | "lowercase-or-initial-uppercase" | Required value: "lowercase-or-initial-uppercase". |
8100
- | `rules.case.other-group-cases` | "forbidden" | Required value: "forbidden". |
8101
- | `rules.stress` | a map | The font's nonprinting stress modifier after an acute vowel. |
8102
- | `rules.stress.modifier` | a string | The font code for U+E0114. |
8103
- | `rules.stress.position` | "after-stressed-vowel" | Required value: "after-stressed-vowel". |
8104
- | `rules.stress.printing` | false | Required value: false. |
8105
- | `rules.stress.effect` | "stress-only" | Required value: "stress-only". |
8106
- | `rules.stress.changes-sound` | false | Required value: false. |
8107
- | `rules.stress.changes-length` | false | Required value: false. |
8108
- | `rules.stress.vowel-group-position` | "first-vowel" | Required value: "first-vowel". |
8109
- | `rules.vowel-groups` | a map | Explicit recognized vowel groups and their acute placement. |
8110
- | `rules.vowel-groups.reading` | "established" | Required value: "established". |
8111
- | `rules.vowel-groups.allow-splitting` | false | Required value: false. |
8112
- | `rules.vowel-groups.unrecognized-adjacent-vowels` | "separate" | Required value: "separate". |
8113
- | `rules.vowel-groups.groups` | a list of vowel groups | The complete authored group inventory. |
8114
- | `rules.diaeresis` | a map | Whether diaeresis mappings are allowed. |
8115
- | `rules.diaeresis.allowed` | false | Required value: false. |
8116
- | `rules.grave-accent` | a map | Whether grave-accent mappings are allowed. |
8117
- | `rules.grave-accent.allowed` | false | Required value: false. |
8118
- | `rules.contextual-mappings` | a list of conditional mappings | Before-vowel alternatives applied before unconditional mappings. |
8119
- | `rules.contextual-mappings[].match` | a string | The source sequence. |
8120
- | `rules.contextual-mappings[].before` | "vowel" | Required value: "vowel". |
8121
- | `rules.contextual-mappings[].emit` | a glyph code or list of codes | The font codes written by this alternative. |
8122
- | `rules.y` | a map | The consonant reading of y. |
8123
- | `rules.y.kind` | "consonant" | Required value: "consonant". |
8124
- | `rules.y.sound` | "y in yes" | Required value: "y in yes". |
8125
- | `rules.y.vowel-reading` | false | Required value: false. |
8126
- | `culture` | `Address` | `settingguide` only, and required there: the culture the guide introduces, naming a lore note with `subType: culture` |
8127
- | `concepts` | list of `{ id, gloss, group }` | A core vocabulary's concept list, any subType. See [the core vocabulary](#the-core-vocabulary) |
8128
- | `date` | `YYYY-MM-DD` | `post` only, and required there: the day the post was published |
8129
- | `author` | string | `post` only: who wrote the post; a post naming none carries the site's byline, the first entry of `authors` |
8130
- | `concepts[].id` | string | Required: lowercase letters, digits and `-`, unique in the package |
8131
- | `concepts[].gloss` | string | Required: what the concept means, in brief |
8132
- | `concepts[].group` | string | Required: the group the report lists the concept under |
8133
- | `metres` | `{ name, beats, kind }[]` | The metres a verse fence's `scan=` names; any subType. See [metres](#metres) |
8134
- | `metres[].name` | string | Required: what `scan=` names it by, lowercase letters, digits and `-`, unique in the package |
8135
- | `metres[].beats` | `integer`, required | The beats of one line, above zero |
8136
- | `metres[].kind` | `accentual` | Required: how the beats are counted |
8137
- | `sounds` | `{ letters?, marks? }` | A spelling guide's letters and marks, for every tongue of the setting; any subType. See [a spelling guide's sounds](#a-spelling-guides-sounds) |
8138
- | `sounds.letters` | `{ letters, kind, like?, place?, ipa?, tongue? }[]` | The letters, each row's keys as a language note's `sounds.letters` |
8139
- | `sounds.letters[].letters` | `string[]`, required | The letters or digraphs the row describes |
8140
- | `sounds.letters[].kind` | `string`, required | What kind of sound they write, as `consonant` or `vowel pair` |
8141
- | `sounds.letters[].like` | `string` | What they sound like |
8142
- | `sounds.letters[].place` | `string` | Where in the mouth they are made |
8143
- | `sounds.letters[].ipa` | `string` | The sounds they write, in the International Phonetic Alphabet |
8144
- | `sounds.letters[].tongue` | an Address, or a list of them | The language notes that write the letters; every tongue where absent |
8145
- | `sounds.marks` | `{ mark, letters, kind, limit }[]` | The marks written on the letters, each row's keys as a language note's `sounds.marks` |
8146
- | `sounds.marks[].mark` | `string`, required | The mark's name, as `acute` |
8147
- | `sounds.marks[].letters` | `string[]`, required | The letters that carry it |
8148
- | `sounds.marks[].kind` | `stress`, `length` or `apart`, required | What it says of its letter |
8149
- | `sounds.marks[].limit` | `one` or `any`, required | How many a word may carry |
8087
+ | `data` property | Values | Description |
8088
+ | ------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
8089
+ | `language` | a configured language alias | The configured language alias an encoding document writes. |
8090
+ | `font` | an installed font slug | The encoding's installed font, which supplies all glyph codes. |
8091
+ | `accepts` | a list of characters or ranges | Accepted Roman letters and additions to standard punctuation. |
8092
+ | `vowels` | a string | The letters a contextual vowel match reads as vowels. |
8093
+ | `mappings` | Roman strings to glyph codes | Roman source strings mapped to codes published by the selected font. |
8094
+ | `rules` | the closed encoding rules vocabulary | Executable normalization, matching, case, stress, vowel groups, accents and contextual mappings. |
8095
+ | `rules.normalization` | "NFC" | Required value: "NFC". |
8096
+ | `rules.matching` | "longest-first" | Required value: "longest-first". |
8097
+ | `rules.roundTrip` | a string | Exact by default; canonical permits aliases and empty code sequences and decodes an authored conventional spelling. |
8098
+ | `rules.word-separators` | a list of nonempty NFC source sequences | Source word dividers written as shared spaces, after contextual alternatives; cannot also be ordinary mappings or replace shared punctuation. |
8099
+ | `rules.case` | a map | Case handling for letters and consonant groups. |
8100
+ | `rules.case.single-letter` | "preserve" | Required value: "preserve". |
8101
+ | `rules.case.consonant-group` | "lowercase-or-initial-uppercase" | Required value: "lowercase-or-initial-uppercase". |
8102
+ | `rules.case.other-group-cases` | "forbidden" | Required value: "forbidden". |
8103
+ | `rules.stress` | a map | The font's nonprinting stress modifier after an acute vowel. |
8104
+ | `rules.stress.modifier` | a string | The font code for U+E0114. |
8105
+ | `rules.stress.position` | "after-stressed-vowel" | Required value: "after-stressed-vowel". |
8106
+ | `rules.stress.printing` | false | Required value: false. |
8107
+ | `rules.stress.effect` | "stress-only" | Required value: "stress-only". |
8108
+ | `rules.stress.changes-sound` | false | Required value: false. |
8109
+ | `rules.stress.changes-length` | false | Required value: false. |
8110
+ | `rules.stress.vowel-group-position` | "first-vowel" | Required value: "first-vowel". |
8111
+ | `rules.vowel-groups` | a map | Explicit recognized vowel groups and their acute placement. |
8112
+ | `rules.vowel-groups.reading` | "established" | Required value: "established". |
8113
+ | `rules.vowel-groups.allow-splitting` | false | Required value: false. |
8114
+ | `rules.vowel-groups.unrecognized-adjacent-vowels` | "separate" | Required value: "separate". |
8115
+ | `rules.vowel-groups.groups` | a list of vowel groups | The complete authored group inventory. |
8116
+ | `rules.diaeresis` | a map | Whether diaeresis mappings are allowed. |
8117
+ | `rules.diaeresis.allowed` | true or false | False forbids the accent; true permits explicitly mapped and accepted characters. |
8118
+ | `rules.grave-accent` | a map | Whether grave-accent mappings are allowed. |
8119
+ | `rules.grave-accent.allowed` | true or false | False forbids the accent; true permits explicitly mapped and accepted characters. |
8120
+ | `rules.contextual-mappings` | a list of conditional mappings | Alternatives conditioned on the following letter or source position, applied before unconditional mappings. |
8121
+ | `rules.contextual-mappings[].match` | a string | The source sequence. |
8122
+ | `rules.contextual-mappings[].before` | a string | Match before a declared vowel or another letter. |
8123
+ | `rules.contextual-mappings[].at` | a string | Match at word start, word end or line start. |
8124
+ | `rules.contextual-mappings[].emit` | a glyph code or list of codes (empty only with canonical round trips) | The font codes written by this alternative. |
8125
+ | `rules.y` | a map | The consonant reading of y. |
8126
+ | `rules.y.kind` | "consonant" | Required value: "consonant". |
8127
+ | `rules.y.sound` | "y in yes" | Required value: "y in yes". |
8128
+ | `rules.y.vowel-reading` | false | Required value: false. |
8129
+ | `culture` | `Address` | `settingguide` only, and required there: the culture the guide introduces, naming a lore note with `subType: culture` |
8130
+ | `concepts` | list of `{ id, gloss, group }` | A core vocabulary's concept list, any subType. See [the core vocabulary](#the-core-vocabulary) |
8131
+ | `date` | `YYYY-MM-DD` | `post` only, and required there: the day the post was published |
8132
+ | `author` | string | `post` only: who wrote the post; a post naming none carries the site's byline, the first entry of `authors` |
8133
+ | `concepts[].id` | string | Required: lowercase letters, digits and `-`, unique in the package |
8134
+ | `concepts[].gloss` | string | Required: what the concept means, in brief |
8135
+ | `concepts[].group` | string | Required: the group the report lists the concept under |
8136
+ | `metres` | `{ name, beats, kind }[]` | The metres a verse fence's `scan=` names; any subType. See [metres](#metres) |
8137
+ | `metres[].name` | string | Required: what `scan=` names it by, lowercase letters, digits and `-`, unique in the package |
8138
+ | `metres[].beats` | `integer`, required | The beats of one line, above zero |
8139
+ | `metres[].kind` | `accentual` | Required: how the beats are counted |
8140
+ | `sounds` | `{ letters?, marks? }` | A spelling guide's letters and marks, for every tongue of the setting; any subType. See [a spelling guide's sounds](#a-spelling-guides-sounds) |
8141
+ | `sounds.letters` | `{ letters, kind, like?, place?, ipa?, tongue? }[]` | The letters, each row's keys as a language note's `sounds.letters` |
8142
+ | `sounds.letters[].letters` | `string[]`, required | The letters or digraphs the row describes |
8143
+ | `sounds.letters[].kind` | `string`, required | What kind of sound they write, as `consonant` or `vowel pair` |
8144
+ | `sounds.letters[].like` | `string` | What they sound like |
8145
+ | `sounds.letters[].place` | `string` | Where in the mouth they are made |
8146
+ | `sounds.letters[].ipa` | `string` | The sounds they write, in the International Phonetic Alphabet |
8147
+ | `sounds.letters[].tongue` | an Address, or a list of them | The language notes that write the letters; every tongue where absent |
8148
+ | `sounds.marks` | `{ mark, letters, kind, limit }[]` | The marks written on the letters, each row's keys as a language note's `sounds.marks` |
8149
+ | `sounds.marks[].mark` | `string`, required | The mark's name, as `acute` |
8150
+ | `sounds.marks[].letters` | `string[]`, required | The letters that carry it |
8151
+ | `sounds.marks[].kind` | `stress`, `length` or `apart`, required | What it says of its letter |
8152
+ | `sounds.marks[].limit` | `one` or `any`, required | How many a word may carry |
8150
8153
 
8151
8154
  A setting guide introduces one culture's view of the setting, so it names that
8152
8155
  culture, and a `doc` of any other `subType` has no culture of its own to name.
@@ -1014,57 +1014,60 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
1014
1014
 
1015
1015
  **Inner keys** — a key a field's value does not declare is an error at its own line and column.
1016
1016
 
1017
- | Key | Shape | Required | Meaning |
1018
- | ------------------------------------------------------ | ------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1019
- | `data.rules.normalization` | "NFC" | yes | Required value: "NFC". |
1020
- | `data.rules.matching` | "longest-first" | yes | Required value: "longest-first". |
1021
- | `data.rules.case` | a map | no | Case handling for letters and consonant groups. |
1022
- | `data.rules.case.single-letter` | "preserve" | yes | Required value: "preserve". |
1023
- | `data.rules.case.consonant-group` | "lowercase-or-initial-uppercase" | yes | Required value: "lowercase-or-initial-uppercase". |
1024
- | `data.rules.case.other-group-cases` | "forbidden" | yes | Required value: "forbidden". |
1025
- | `data.rules.stress` | a map | no | The font's nonprinting stress modifier after an acute vowel. |
1026
- | `data.rules.stress.modifier` | a string | yes | The font code for U+E0114. |
1027
- | `data.rules.stress.position` | "after-stressed-vowel" | yes | Required value: "after-stressed-vowel". |
1028
- | `data.rules.stress.printing` | false | yes | Required value: false. |
1029
- | `data.rules.stress.effect` | "stress-only" | yes | Required value: "stress-only". |
1030
- | `data.rules.stress.changes-sound` | false | yes | Required value: false. |
1031
- | `data.rules.stress.changes-length` | false | yes | Required value: false. |
1032
- | `data.rules.stress.vowel-group-position` | "first-vowel" | yes | Required value: "first-vowel". |
1033
- | `data.rules.vowel-groups` | a map | no | Explicit recognized vowel groups and their acute placement. |
1034
- | `data.rules.vowel-groups.reading` | "established" | yes | Required value: "established". |
1035
- | `data.rules.vowel-groups.allow-splitting` | false | yes | Required value: false. |
1036
- | `data.rules.vowel-groups.unrecognized-adjacent-vowels` | "separate" | yes | Required value: "separate". |
1037
- | `data.rules.vowel-groups.groups` | a list of vowel groups | yes | The complete authored group inventory. |
1038
- | `data.rules.diaeresis` | a map | no | Whether diaeresis mappings are allowed. |
1039
- | `data.rules.diaeresis.allowed` | false | yes | Required value: false. |
1040
- | `data.rules.grave-accent` | a map | no | Whether grave-accent mappings are allowed. |
1041
- | `data.rules.grave-accent.allowed` | false | yes | Required value: false. |
1042
- | `data.rules.contextual-mappings` | a list of conditional mappings | no | Before-vowel alternatives applied before unconditional mappings. |
1043
- | `data.rules.contextual-mappings[].match` | a string | yes | The source sequence. |
1044
- | `data.rules.contextual-mappings[].before` | "vowel" | yes | Required value: "vowel". |
1045
- | `data.rules.contextual-mappings[].emit` | a glyph code or list of codes | yes | The font codes written by this alternative. |
1046
- | `data.rules.y` | a map | no | The consonant reading of y. |
1047
- | `data.rules.y.kind` | "consonant" | yes | Required value: "consonant". |
1048
- | `data.rules.y.sound` | "y in yes" | yes | Required value: "y in yes". |
1049
- | `data.rules.y.vowel-reading` | false | yes | Required value: false. |
1050
- | `data.concepts[].id` | lowercase letters, digits and `-` | yes | The concept's id, which a word's `concept` names. Unique in the package. |
1051
- | `data.concepts[].gloss` | a short gloss, such as `a smell; a scent` | yes | What the concept means, in brief. |
1052
- | `data.concepts[].group` | a group name, such as `senses` | yes | The group the report lists the concept under. |
1053
- | `data.sounds.letters` | list of `{ letters, kind, like?, place?, ipa?, tongue? }` | no | The letters the language writes, and the sounds they stand for. |
1054
- | `data.sounds.letters[].letters` | a list of letters | yes | The letters or digraphs the row describes, as written. |
1055
- | `data.sounds.letters[].kind` | a string, such as `consonant` or `long vowel` | yes | What kind of sound they write. |
1056
- | `data.sounds.letters[].like` | a string | no | What they sound like; absent where the row states only their kind and place. |
1057
- | `data.sounds.letters[].place` | a string, such as `lips` or `tongue-tip` | no | Where in the mouth they are made. |
1058
- | `data.sounds.letters[].ipa` | a string in the International Phonetic Alphabet, such as `ʃ` | no | The sounds they write, in the International Phonetic Alphabet. |
1059
- | `data.sounds.letters[].tongue` | a tag or a language note's Address, or a list of them | no | The tongues that write them, each a tag `data.tongues` declares or the Address of a language note; every one where absent. A `doc` note's rows name language notes only. |
1060
- | `data.sounds.marks` | list of `{ mark, letters, kind, limit }` | no | The marks the language writes on its letters. |
1061
- | `data.sounds.marks[].mark` | a string, such as `acute` | yes | The mark's name. |
1062
- | `data.sounds.marks[].letters` | a list of letters | yes | The letters that carry it. |
1063
- | `data.sounds.marks[].kind` | one of stress, length, apart | yes | What the mark says of its letter. |
1064
- | `data.sounds.marks[].limit` | one of one, any | yes | How many a word may carry: `one` at most, or `any`. |
1065
- | `data.metres[].name` | string | yes | What a verse fence's `scan=` names the metre by: lowercase letters, digits and `-`, unique in the package. |
1066
- | `data.metres[].beats` | a whole number above zero | yes | The beats of one line. |
1067
- | `data.metres[].kind` | one of accentual | yes | How the beats are counted: `accentual`, one beat for each stressed syllable the language's stress rule and the beat convention give. |
1017
+ | Key | Shape | Required | Meaning |
1018
+ | ------------------------------------------------------ | --------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1019
+ | `data.rules.normalization` | "NFC" | yes | Required value: "NFC". |
1020
+ | `data.rules.matching` | "longest-first" | yes | Required value: "longest-first". |
1021
+ | `data.rules.roundTrip` | a string | no | Exact by default; canonical permits aliases and empty code sequences and decodes an authored conventional spelling. |
1022
+ | `data.rules.word-separators` | a list of nonempty NFC source sequences | no | Source word dividers written as shared spaces, after contextual alternatives; cannot also be ordinary mappings or replace shared punctuation. |
1023
+ | `data.rules.case` | a map | no | Case handling for letters and consonant groups. |
1024
+ | `data.rules.case.single-letter` | "preserve" | yes | Required value: "preserve". |
1025
+ | `data.rules.case.consonant-group` | "lowercase-or-initial-uppercase" | yes | Required value: "lowercase-or-initial-uppercase". |
1026
+ | `data.rules.case.other-group-cases` | "forbidden" | yes | Required value: "forbidden". |
1027
+ | `data.rules.stress` | a map | no | The font's nonprinting stress modifier after an acute vowel. |
1028
+ | `data.rules.stress.modifier` | a string | yes | The font code for U+E0114. |
1029
+ | `data.rules.stress.position` | "after-stressed-vowel" | yes | Required value: "after-stressed-vowel". |
1030
+ | `data.rules.stress.printing` | false | yes | Required value: false. |
1031
+ | `data.rules.stress.effect` | "stress-only" | yes | Required value: "stress-only". |
1032
+ | `data.rules.stress.changes-sound` | false | yes | Required value: false. |
1033
+ | `data.rules.stress.changes-length` | false | yes | Required value: false. |
1034
+ | `data.rules.stress.vowel-group-position` | "first-vowel" | yes | Required value: "first-vowel". |
1035
+ | `data.rules.vowel-groups` | a map | no | Explicit recognized vowel groups and their acute placement. |
1036
+ | `data.rules.vowel-groups.reading` | "established" | yes | Required value: "established". |
1037
+ | `data.rules.vowel-groups.allow-splitting` | false | yes | Required value: false. |
1038
+ | `data.rules.vowel-groups.unrecognized-adjacent-vowels` | "separate" | yes | Required value: "separate". |
1039
+ | `data.rules.vowel-groups.groups` | a list of vowel groups | yes | The complete authored group inventory. |
1040
+ | `data.rules.diaeresis` | a map | no | Whether diaeresis mappings are allowed. |
1041
+ | `data.rules.diaeresis.allowed` | true or false | yes | False forbids the accent; true permits explicitly mapped and accepted characters. |
1042
+ | `data.rules.grave-accent` | a map | no | Whether grave-accent mappings are allowed. |
1043
+ | `data.rules.grave-accent.allowed` | true or false | yes | False forbids the accent; true permits explicitly mapped and accepted characters. |
1044
+ | `data.rules.contextual-mappings` | a list of conditional mappings | no | Alternatives conditioned on the following letter or source position, applied before unconditional mappings. |
1045
+ | `data.rules.contextual-mappings[].match` | a string | yes | The source sequence. |
1046
+ | `data.rules.contextual-mappings[].before` | a string | no | Match before a declared vowel or another letter. |
1047
+ | `data.rules.contextual-mappings[].at` | a string | no | Match at word start, word end or line start. |
1048
+ | `data.rules.contextual-mappings[].emit` | a glyph code or list of codes (empty only with canonical round trips) | yes | The font codes written by this alternative. |
1049
+ | `data.rules.y` | a map | no | The consonant reading of y. |
1050
+ | `data.rules.y.kind` | "consonant" | yes | Required value: "consonant". |
1051
+ | `data.rules.y.sound` | "y in yes" | yes | Required value: "y in yes". |
1052
+ | `data.rules.y.vowel-reading` | false | yes | Required value: false. |
1053
+ | `data.concepts[].id` | lowercase letters, digits and `-` | yes | The concept's id, which a word's `concept` names. Unique in the package. |
1054
+ | `data.concepts[].gloss` | a short gloss, such as `a smell; a scent` | yes | What the concept means, in brief. |
1055
+ | `data.concepts[].group` | a group name, such as `senses` | yes | The group the report lists the concept under. |
1056
+ | `data.sounds.letters` | list of `{ letters, kind, like?, place?, ipa?, tongue? }` | no | The letters the language writes, and the sounds they stand for. |
1057
+ | `data.sounds.letters[].letters` | a list of letters | yes | The letters or digraphs the row describes, as written. |
1058
+ | `data.sounds.letters[].kind` | a string, such as `consonant` or `long vowel` | yes | What kind of sound they write. |
1059
+ | `data.sounds.letters[].like` | a string | no | What they sound like; absent where the row states only their kind and place. |
1060
+ | `data.sounds.letters[].place` | a string, such as `lips` or `tongue-tip` | no | Where in the mouth they are made. |
1061
+ | `data.sounds.letters[].ipa` | a string in the International Phonetic Alphabet, such as `ʃ` | no | The sounds they write, in the International Phonetic Alphabet. |
1062
+ | `data.sounds.letters[].tongue` | a tag or a language note's Address, or a list of them | no | The tongues that write them, each a tag `data.tongues` declares or the Address of a language note; every one where absent. A `doc` note's rows name language notes only. |
1063
+ | `data.sounds.marks` | list of `{ mark, letters, kind, limit }` | no | The marks the language writes on its letters. |
1064
+ | `data.sounds.marks[].mark` | a string, such as `acute` | yes | The mark's name. |
1065
+ | `data.sounds.marks[].letters` | a list of letters | yes | The letters that carry it. |
1066
+ | `data.sounds.marks[].kind` | one of stress, length, apart | yes | What the mark says of its letter. |
1067
+ | `data.sounds.marks[].limit` | one of one, any | yes | How many a word may carry: `one` at most, or `any`. |
1068
+ | `data.metres[].name` | string | yes | What a verse fence's `scan=` names the metre by: lowercase letters, digits and `-`, unique in the package. |
1069
+ | `data.metres[].beats` | a whole number above zero | yes | The beats of one line. |
1070
+ | `data.metres[].kind` | one of accentual | yes | How the beats are counted: `accentual`, one beat for each stressed syllable the language's stress rule and the beat convention give. |
1068
1071
 
1069
1072
  ### macro
1070
1073
 
@@ -20,7 +20,7 @@ import { isNoteRecord } from "./index-records.mjs";
20
20
  import { nearest } from "./near-miss.mjs";
21
21
  import { isLanguageRecord, lineageChain, noteAddressOf } from "./word-rows.mjs";
22
22
  import { derivedForms, foldWord } from "./word-forms.mjs";
23
- import { WRITTEN_ENDINGS } from "./language-terms.mjs";
23
+ import { WRITTEN_ENDINGS, RESERVED_TONGUE } from "./language-terms.mjs";
24
24
  import { LANGUAGE_SUBTYPE, glossText } from "./word-terms.mjs";
25
25
  import { writingsOf } from "./language-writings.mjs";
26
26
  import { encodeText, validateSource, EncodingError } from "./encoding.mjs";
@@ -658,7 +658,9 @@ export function checkEncodingLexicon(note, ctx) {
658
658
  );
659
659
  listOf(element?.examples).forEach((form, j) => add(form, ["elements", i, "examples", j]));
660
660
  });
661
- listOf(data.register).forEach((name, i) => add(name?.name, ["register", i, "name"]));
661
+ listOf(data.register).forEach((name, i) => {
662
+ if (name?.tongue !== RESERVED_TONGUE) add(name?.name, ["register", i, "name"]);
663
+ });
662
664
  const findings = [],
663
665
  seen = new Set();
664
666
  for (const encoding of encodings)
@@ -61,10 +61,13 @@ export function templateProgram(data, options, fault) {
61
61
  "A glyph code must resolve to nonempty Unicode scalar text",
62
62
  );
63
63
  }
64
- const resolve = (value, key) => {
64
+ const resolve = (value, key, allowEmpty = false) => {
65
65
  const sequence = typeof value === "string" ? [value] : value;
66
- if (!Array.isArray(sequence) || !sequence.length)
67
- fault(key, "A mapping is a glyph code or a nonempty sequence of glyph codes");
66
+ if (!Array.isArray(sequence) || (!sequence.length && !allowEmpty))
67
+ fault(
68
+ key,
69
+ "A mapping is a glyph code or a code sequence; an empty sequence requires canonical round trips",
70
+ );
68
71
  return sequence
69
72
  .map((name) => {
70
73
  if (
@@ -82,6 +85,8 @@ export function templateProgram(data, options, fault) {
82
85
  [
83
86
  "normalization",
84
87
  "matching",
88
+ "roundTrip",
89
+ "word-separators",
85
90
  "case",
86
91
  "stress",
87
92
  "vowel-groups",
@@ -95,6 +100,9 @@ export function templateProgram(data, options, fault) {
95
100
  const rules = data.rules;
96
101
  exact(rules.normalization, "NFC", "rules.normalization");
97
102
  exact(rules.matching, "longest-first", "rules.matching");
103
+ if (rules.roundTrip !== undefined && !["exact", "canonical"].includes(rules.roundTrip))
104
+ fault("rules.roundTrip", "roundTrip is exact or canonical");
105
+ const canonical = rules.roundTrip === "canonical";
98
106
  if (rules.case !== undefined)
99
107
  fixed(
100
108
  rules.case,
@@ -106,7 +114,11 @@ export function templateProgram(data, options, fault) {
106
114
  "rules.case",
107
115
  );
108
116
  for (const key of ["diaeresis", "grave-accent"])
109
- if (rules[key] !== undefined) fixed(rules[key], { allowed: false }, `rules.${key}`);
117
+ if (rules[key] !== undefined) {
118
+ closed(rules[key], ["allowed"], `rules.${key}`);
119
+ if (typeof rules[key].allowed !== "boolean")
120
+ fault(`rules.${key}.allowed`, "allowed must be true or false");
121
+ }
110
122
  const vowels = data.vowels ?? "";
111
123
  if (!scalar(vowels)) fault("vowels", "vowels must be a Unicode scalar string");
112
124
  const vowelSet = new Set([...vowels].map(base));
@@ -189,8 +201,9 @@ export function templateProgram(data, options, fault) {
189
201
  if (/[\p{White_Space}⟦⟧]/u.test(from))
190
202
  fault(key, "Whitespace and literal markers belong to the shared standard");
191
203
  const decomposed = from.normalize("NFD");
192
- if (rules.diaeresis && decomposed.includes("\u0308")) fault(key, "Diaeresis is forbidden");
193
- if (rules["grave-accent"] && decomposed.includes("\u0300"))
204
+ if (rules.diaeresis?.allowed === false && decomposed.includes("\u0308"))
205
+ fault(key, "Diaeresis is forbidden");
206
+ if (rules["grave-accent"]?.allowed === false && decomposed.includes("\u0300"))
194
207
  fault(key, "Grave accents are forbidden");
195
208
  };
196
209
  const validateOutput = (from, to, key) => {
@@ -215,10 +228,36 @@ export function templateProgram(data, options, fault) {
215
228
  for (const [from, output] of Object.entries(data.mappings)) {
216
229
  const key = `mappings.${from}`;
217
230
  validateSource(from, key);
218
- const to = resolve(output, key);
231
+ const to = resolve(output, key, canonical);
219
232
  validateOutput(from, to, key);
220
233
  mappings.push([from, to]);
221
234
  }
235
+ const separators = [];
236
+ if (rules["word-separators"] !== undefined) {
237
+ if (!Array.isArray(rules["word-separators"]))
238
+ fault("rules.word-separators", "word-separators must be a list");
239
+ const seen = new Set();
240
+ for (const [i, from] of rules["word-separators"].entries()) {
241
+ const key = `rules.word-separators.${i}`;
242
+ validateSource(from, key);
243
+ if (
244
+ [...from].some(
245
+ (char) =>
246
+ "^/{}".includes(char) || /[\p{Cc}\p{Cf}\u{E0100}-\u{E01EF}]/u.test(char),
247
+ ) ||
248
+ STANDARD_ROWS.some((row) => (Array.isArray(row) ? row[0] : row.from) === from)
249
+ )
250
+ fault(
251
+ key,
252
+ "Word separators cannot replace shared punctuation or contain structural controls",
253
+ );
254
+ if (seen.has(from)) fault(key, "A word separator is stated twice");
255
+ if (Object.hasOwn(data.mappings, from))
256
+ fault(key, "A word separator cannot also have an ordinary mapping");
257
+ seen.add(from);
258
+ separators.push({ from, to: STANDARD_CONTROLS.wordSeparator });
259
+ }
260
+ }
222
261
  const contextual = [];
223
262
  if (rules["contextual-mappings"] !== undefined) {
224
263
  if (!Array.isArray(rules["contextual-mappings"]))
@@ -226,21 +265,36 @@ export function templateProgram(data, options, fault) {
226
265
  const seen = new Set();
227
266
  for (const [i, row] of rules["contextual-mappings"].entries()) {
228
267
  const key = `rules.contextual-mappings.${i}`;
229
- closed(row, ["match", "before", "emit"], key);
230
- if (
231
- !scalar(row.match) ||
232
- !row.match ||
233
- row.match !== row.match.normalize("NFC") ||
234
- seen.has(row.match)
235
- )
236
- fault(`${key}.match`, "A contextual match must be unique nonempty NFC text");
237
- exact(row.before, "vowel", `${key}.before`);
238
- if (!vowels) fault(`${key}.before`, "Before-vowel mappings require declared vowels");
268
+ closed(row, ["match", "before", "at", "emit"], key);
269
+ if (!scalar(row.match) || !row.match || row.match !== row.match.normalize("NFC"))
270
+ fault(`${key}.match`, "A contextual match must be nonempty NFC text");
271
+ if (row.before === undefined && row.at === undefined)
272
+ fault(key, "A contextual mapping requires before or at");
273
+ if (row.before !== undefined && !["vowel", "consonant"].includes(row.before))
274
+ fault(`${key}.before`, "before is vowel or consonant");
275
+ if (row.at !== undefined && !["start", "end", "line-start"].includes(row.at))
276
+ fault(`${key}.at`, "at is start, end or line-start");
277
+ if (row.before && !vowels)
278
+ fault(
279
+ `${key}.before`,
280
+ "Before-vowel or before-consonant mappings require declared vowels",
281
+ );
282
+ const condition = JSON.stringify([row.match, row.before ?? null, row.at ?? null]);
283
+ if (seen.has(condition))
284
+ fault(
285
+ `${key}.match`,
286
+ "A contextual mapping repeats the same source and conditions",
287
+ );
239
288
  validateSource(row.match, `${key}.match`);
240
- const to = resolve(row.emit, `${key}.emit`);
289
+ const to = resolve(row.emit, `${key}.emit`, canonical);
241
290
  validateOutput(row.match, to, `${key}.emit`);
242
- seen.add(row.match);
243
- contextual.push({ from: row.match, to, before: "vowel" });
291
+ seen.add(condition);
292
+ contextual.push({
293
+ from: row.match,
294
+ to,
295
+ ...(row.before !== undefined ? { before: row.before } : {}),
296
+ ...(row.at !== undefined ? { at: row.at } : {}),
297
+ });
244
298
  }
245
299
  }
246
300
  if (rules.case)
@@ -256,26 +310,32 @@ export function templateProgram(data, options, fault) {
256
310
  ...STANDARD_ROWS.filter((row) => !overridden.has(Array.isArray(row) ? row[0] : row.from)),
257
311
  ...contextual,
258
312
  ...mappings,
313
+ ...separators,
259
314
  ];
260
- assertUniqueOutputs(
261
- [
262
- ...rows.map((row) => {
263
- if (Array.isArray(row))
264
- return { from: row[0], to: row[1], key: `mappings.${row[0]}` };
265
- const index = contextual.indexOf(row);
266
- return {
267
- ...row,
268
- key: index < 0 ? "rules" : `rules.contextual-mappings.${index}.emit`,
269
- };
270
- }),
271
- { from: " ", to: " ", key: "rules" },
272
- ],
273
- fault,
274
- );
315
+ const sourceKey = (row) => {
316
+ if (Array.isArray(row)) return `mappings.${row[0]}`;
317
+ const separator = separators.indexOf(row);
318
+ if (separator >= 0) return `rules.word-separators.${separator}`;
319
+ const context = contextual.indexOf(row);
320
+ return context < 0 ? "rules" : `rules.contextual-mappings.${context}`;
321
+ };
322
+ if (!canonical)
323
+ assertUniqueOutputs(
324
+ [
325
+ ...rows.map((row) =>
326
+ Array.isArray(row) ?
327
+ { from: row[0], to: row[1], key: sourceKey(row) }
328
+ : { ...row, key: sourceKey(row) },
329
+ ),
330
+ { from: " ", to: " ", key: "rules" },
331
+ ],
332
+ fault,
333
+ );
275
334
  const accepts = data.accepts ?? [
276
335
  ...new Set([
277
336
  ...Object.keys(data.mappings).join(""),
278
337
  ...contextual.map((r) => r.from).join(""),
338
+ ...separators.map((row) => row.from).join(""),
279
339
  ]),
280
340
  ];
281
341
  if (!Array.isArray(accepts)) fault("accepts", "accepts must be a list of characters or ranges");
@@ -285,17 +345,14 @@ export function templateProgram(data, options, fault) {
285
345
  fonts: [data.font],
286
346
  accepts: [...accepts, ...STANDARD_ROWS.map((r) => (Array.isArray(r) ? r[0] : r.from))],
287
347
  separator: " ",
348
+ roundTrip: canonical ? "canonical" : "exact",
288
349
  vowels,
289
350
  rows,
290
351
  codes: Object.entries(options.codes),
291
352
  controls: STANDARD_CONTROLS,
292
353
  ...(rules.case ? { groupCase: "initial" } : {}),
293
354
  },
294
- rowKeys: rows.map((row) => {
295
- if (Array.isArray(row)) return `mappings.${row[0]}`;
296
- const index = contextual.indexOf(row);
297
- return index < 0 ? "rules" : `rules.contextual-mappings.${index}`;
298
- }),
355
+ rowKeys: rows.map(sourceKey),
299
356
  metadata: {
300
357
  name: options.name ?? `${data.language}-${data.font}`,
301
358
  language: data.language,
@@ -305,8 +362,8 @@ export function templateProgram(data, options, fault) {
305
362
  vowelGroups: Object.freeze(vowelGroups),
306
363
  maxVowelGroup: Math.max(0, ...vowelGroups.map((group) => [...group].length)),
307
364
  stress,
308
- diaeresis: !!rules.diaeresis,
309
- grave: !!rules["grave-accent"],
365
+ diaeresis: rules.diaeresis?.allowed === false,
366
+ grave: rules["grave-accent"]?.allowed === false,
310
367
  }),
311
368
  },
312
369
  };
@@ -28,7 +28,7 @@ const fixed = (name, value) => ({
28
28
  const block = (name, describe, fields) => ({ name, kind: "map", shape: "a map", describe, fields });
29
29
  const sequence = {
30
30
  kind: "string-or-list",
31
- shape: "a glyph code or list of codes",
31
+ shape: "a glyph code or list of codes (empty only with canonical round trips)",
32
32
  entries: {
33
33
  kind: "string",
34
34
  shape: "a glyph code",
@@ -39,6 +39,25 @@ const sequence = {
39
39
  export const ENCODING_RULE_FIELDS = Object.freeze([
40
40
  fixed("normalization", "NFC"),
41
41
  fixed("matching", "longest-first"),
42
+ {
43
+ ...text(
44
+ "roundTrip",
45
+ "Exact by default; canonical permits aliases and empty code sequences and decodes an authored conventional spelling.",
46
+ ),
47
+ allowed: ["exact", "canonical"],
48
+ },
49
+ {
50
+ name: "word-separators",
51
+ kind: "list",
52
+ shape: "a list of nonempty NFC source sequences",
53
+ entries: {
54
+ kind: "string",
55
+ shape: "a word separator",
56
+ describe: "A source sequence that writes the shared word space.",
57
+ },
58
+ describe:
59
+ "Source word dividers written as shared spaces, after contextual alternatives; cannot also be ordinary mappings or replace shared punctuation.",
60
+ },
42
61
  block("case", "Case handling for letters and consonant groups.", [
43
62
  fixed("single-letter", "preserve"),
44
63
  fixed("consonant-group", "lowercase-or-initial-uppercase"),
@@ -70,19 +89,37 @@ export const ENCODING_RULE_FIELDS = Object.freeze([
70
89
  describe: "The complete authored group inventory.",
71
90
  },
72
91
  ]),
73
- block("diaeresis", "Whether diaeresis mappings are allowed.", [fixed("allowed", false)]),
74
- block("grave-accent", "Whether grave-accent mappings are allowed.", [fixed("allowed", false)]),
92
+ ...["diaeresis", "grave-accent"].map((name) =>
93
+ block(name, `Whether ${name} mappings are allowed.`, [
94
+ {
95
+ name: "allowed",
96
+ kind: "boolean",
97
+ shape: "true or false",
98
+ required: true,
99
+ describe:
100
+ "False forbids the accent; true permits explicitly mapped and accepted characters.",
101
+ },
102
+ ]),
103
+ ),
75
104
  {
76
105
  name: "contextual-mappings",
77
106
  kind: "list",
78
107
  shape: "a list of conditional mappings",
79
- describe: "Before-vowel alternatives applied before unconditional mappings.",
108
+ describe:
109
+ "Alternatives conditioned on the following letter or source position, applied before unconditional mappings.",
80
110
  entries: {
81
111
  kind: "map",
82
112
  shape: "a contextual mapping",
83
113
  fields: [
84
114
  text("match", "The source sequence.", true),
85
- fixed("before", "vowel"),
115
+ {
116
+ ...text("before", "Match before a declared vowel or another letter."),
117
+ allowed: ["vowel", "consonant"],
118
+ },
119
+ {
120
+ ...text("at", "Match at word start, word end or line start."),
121
+ allowed: ["start", "end", "line-start"],
122
+ },
86
123
  {
87
124
  name: "emit",
88
125
  ...sequence,
@@ -322,15 +322,13 @@ function boundary(char) {
322
322
  // Python str.isspace also includes the four information separators.
323
323
  return SPACE.test(char) || /[\u001C-\u001F]/u.test(char) || BOUNDARY.test(char);
324
324
  }
325
+ function beforeHolds(condition, char, vowels) {
326
+ return Boolean(
327
+ char && (condition === "vowel" ? vowels.has(char) : !vowels.has(char) && LETTER.test(char)),
328
+ );
329
+ }
325
330
  function holds(row, text, start, end, vowels) {
326
- if (row.before) {
327
- const char = firstAt(text, end);
328
- if (
329
- !char ||
330
- (row.before === "vowel" ? !vowels.has(char) : vowels.has(char) || !LETTER.test(char))
331
- )
332
- return false;
333
- }
331
+ if (row.before && !beforeHolds(row.before, firstAt(text, end), vowels)) return false;
334
332
  if (row.at === "line-start") {
335
333
  const prefix = text.slice(
336
334
  Math.max(text.lastIndexOf("\n", start - 1), text.lastIndexOf("/", start - 1)) + 1,
@@ -834,7 +832,7 @@ function decodeRawText(enc, text, { font } = {}) {
834
832
  }
835
833
 
836
834
  /**
837
- * Decode a note's uniquely decodable output, including nonprefix glyph sequences.
835
+ * Decode exact output or the conventional reading of an explicitly canonical note.
838
836
  * @param {ReturnType<typeof compileEncoding>} enc - Compiled encoding note.
839
837
  * @param {string} text - Native text with optional literal spans.
840
838
  * @param {{font?: string}} [options] - Selected encoding face.
@@ -845,17 +843,19 @@ export function decodeText(enc, text, options = {}) {
845
843
  const readings = tiers(enc, options.font)[0].readings;
846
844
  if (typeof text !== "string") throw new EncodingError("Text must be a string");
847
845
  if (!templateReaders.has(enc)) {
848
- const root = { children: new Map(), row: undefined };
849
- for (const group of readings.values())
850
- for (const row of group) {
851
- let node = root;
852
- for (const char of row.to) {
853
- if (!node.children.has(char))
854
- node.children.set(char, { children: new Map(), row: undefined });
855
- node = node.children.get(char);
856
- }
857
- node.row = row;
846
+ const root = { children: new Map(), row: undefined, rows: [] };
847
+ const rows = new Set([...readings.values()].flat().concat(enc.shared.rows));
848
+ for (const row of rows) {
849
+ if (!row.to) continue;
850
+ let node = root;
851
+ for (const char of row.to) {
852
+ if (!node.children.has(char))
853
+ node.children.set(char, { children: new Map(), row: undefined, rows: [] });
854
+ node = node.children.get(char);
858
855
  }
856
+ node.row ??= row;
857
+ node.rows.push(row);
858
+ }
859
859
  templateReaders.set(enc, root);
860
860
  }
861
861
  const reader = templateReaders.get(enc);
@@ -888,9 +888,15 @@ export function decodeText(enc, text, options = {}) {
888
888
  cursor = cursor.children.get(char);
889
889
  if (!cursor) break;
890
890
  next += char.length;
891
- const row = cursor.row;
892
- if (!row || !paths.has(next)) continue;
893
- if (row.before && !enc.vowels.has(firstAt(paths.get(next).from, 0))) {
891
+ if (!cursor.row || !paths.has(next)) continue;
892
+ const following = firstAt(paths.get(next).from, 0);
893
+ const preferred = cursor.rows.find(
894
+ (row) => !row.before || beforeHolds(row.before, following, enc.vowels),
895
+ );
896
+ // Canonical source can omit the vowel or marker that selected a form.
897
+ // Prefer applicable aliases, then retain the first authored reading.
898
+ const row = preferred ?? (enc.roundTrip === "canonical" ? cursor.row : undefined);
899
+ if (!row) {
894
900
  failedContext = Math.min(failedContext, at);
895
901
  continue;
896
902
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.1.0",
3
+ "version": "25.1.1",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -14,6 +14,7 @@ export function templateProgram(data: any, options: {
14
14
  fonts: any[];
15
15
  accepts: any[];
16
16
  separator: string;
17
+ roundTrip: string;
17
18
  vowels: any;
18
19
  rows: Readonly<unknown>[];
19
20
  codes: [string, string][];
@@ -12,6 +12,17 @@ export const ENCODING_RULE_FIELDS: readonly ({
12
12
  shape: string;
13
13
  describe: any;
14
14
  fields: any;
15
+ } | {
16
+ name: string;
17
+ kind: string;
18
+ shape: string;
19
+ entries: {
20
+ kind: string;
21
+ shape: string;
22
+ describe: string;
23
+ fields?: undefined;
24
+ };
25
+ describe: string;
15
26
  } | {
16
27
  name: string;
17
28
  kind: string;
@@ -20,13 +31,21 @@ export const ENCODING_RULE_FIELDS: readonly ({
20
31
  entries: {
21
32
  kind: string;
22
33
  shape: string;
23
- fields: {
34
+ fields: ({
35
+ name: any;
36
+ kind: string;
37
+ shape: string;
38
+ describe: any;
39
+ required: boolean;
40
+ } | {
41
+ allowed: string[];
24
42
  name: any;
25
43
  kind: string;
26
44
  shape: string;
27
45
  describe: any;
28
46
  required: boolean;
29
- }[];
47
+ })[];
48
+ describe?: undefined;
30
49
  };
31
50
  })[];
32
51
  /** The additional data keys a document may use to define its encoding. */
@@ -82,6 +101,17 @@ export const ENCODING_NOTE_FIELDS: readonly ({
82
101
  shape: string;
83
102
  describe: any;
84
103
  fields: any;
104
+ } | {
105
+ name: string;
106
+ kind: string;
107
+ shape: string;
108
+ entries: {
109
+ kind: string;
110
+ shape: string;
111
+ describe: string;
112
+ fields?: undefined;
113
+ };
114
+ describe: string;
85
115
  } | {
86
116
  name: string;
87
117
  kind: string;
@@ -90,13 +120,21 @@ export const ENCODING_NOTE_FIELDS: readonly ({
90
120
  entries: {
91
121
  kind: string;
92
122
  shape: string;
93
- fields: {
123
+ fields: ({
124
+ name: any;
125
+ kind: string;
126
+ shape: string;
127
+ describe: any;
128
+ required: boolean;
129
+ } | {
130
+ allowed: string[];
94
131
  name: any;
95
132
  kind: string;
96
133
  shape: string;
97
134
  describe: any;
98
135
  required: boolean;
99
- }[];
136
+ })[];
137
+ describe?: undefined;
100
138
  };
101
139
  })[];
102
140
  describe: string;
@@ -128,7 +128,7 @@ export function encodeText(enc: ReturnType<typeof compileEncoding>, text: string
128
128
  verse?: boolean;
129
129
  }): string;
130
130
  /**
131
- * Decode a note's uniquely decodable output, including nonprefix glyph sequences.
131
+ * Decode exact output or the conventional reading of an explicitly canonical note.
132
132
  * @param {ReturnType<typeof compileEncoding>} enc - Compiled encoding note.
133
133
  * @param {string} text - Native text with optional literal spans.
134
134
  * @param {{font?: string}} [options] - Selected encoding face.