@heroiclands/package-build 25.6.0 → 25.6.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,12 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.6.1
4
+
5
+ **Languages and scripts**
6
+
7
+ - Scripts that allow diaeresis can read back the marks in split vowel groups when their writing keeps joined and separate vowels distinct.
8
+ - Language notes and spelling guides can declare pronunciation marks for rising or falling pitch with `kind: tone`.
9
+
3
10
  ## 25.6.0
4
11
 
5
12
  **Changes to act on**
@@ -53,15 +53,16 @@ second behaviour.
53
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. |
54
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. |
55
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. |
56
- | `diaeresis` | `{allowed: boolean}` permits or forbids explicitly mapped diaeresis characters. |
56
+ | `diaeresis` | `{allowed: boolean}`; false forbids diaeresis characters; true derives plain-vowel mappings and restores splits of distinctly mapped declared groups. |
57
57
  | `grave-accent` | `{allowed: boolean}` permits or forbids explicitly mapped grave-accent characters. |
58
58
  | `contextual-mappings` | A list of `{match, emit, before?, at?}`; following-letter or source-position conditions precede unconditional mappings. |
59
59
  | `y` | `{kind: consonant, sound: "y in yes", vowel-reading: false}`; y cannot also appear in `data.vowels`. |
60
60
 
61
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.
62
+ accent. True on the grave accent permits it only where the character is
63
+ accepted and mapped; true on the diaeresis turns on the
64
+ [diaeresis rule](#the-diaeresis-by-rule). Omitting either block permits
65
+ explicit mappings alone, and no choice invents an accent pronunciation.
65
66
 
66
67
  `rules.roundTrip` is optional and defaults to `exact`. Exact mappings must be
67
68
  uniquely decodable across adjacent mappings and spaces; ambiguous outputs and
@@ -116,7 +117,8 @@ canonical round trips. Decoding keeps the shared space as a space.
116
117
  A contextual match cannot contain whitespace, literal markers or forbidden
117
118
  accents. Both modes enforce the same accepted characters, normalization,
118
119
  case, stress, accent and literal-control rules. The compiler does not infer
119
- linguistic vowel groups or silently add missing letter mappings.
120
+ linguistic vowel groups or silently add missing plain-letter mappings. The
121
+ [diaeresis rule](#the-diaeresis-by-rule) uses only explicitly declared groups.
120
122
 
121
123
  Lint checks every authored word, variant and registered name for a language
122
124
  with an encoding document, including unused entries. It also checks actual
@@ -147,6 +149,42 @@ text to the surrounding sentence. Names, literal codage and independent
147
149
  language children count as text. An explicit mapping can give a standard
148
150
  source its own font glyph. Other punctuation needs its own mapping.
149
151
 
152
+ ### The diaeresis by rule
153
+
154
+ A diaeresis splits a listed vowel group: _kau_ uses the group _au_, while
155
+ _kaü_ sounds its vowels apart. Adjacent vowels outside the declared groups
156
+ already sound separately and need no mark: _kae_ remains _kae_.
157
+
158
+ `diaeresis: {allowed: true}` accepts _ä_, _ë_, _ï_, _ö_ and _ü_, and their
159
+ capitals, using the corresponding plain vowel's mappings and contextual
160
+ rules. No extra mapping rows are needed. This applies only when the plain
161
+ vowel has a mapping of its own and no source explicitly names that diaeresis
162
+ vowel. Explicit mappings retain their authored spelling and glyphs. A derived
163
+ diaeresis blocks a row from joining the preceding vowel, while permitting
164
+ matching from the marked vowel onward. Source pieces and errors retain their
165
+ authored UTF-16 offsets, including decomposed input normalized to NFC.
166
+
167
+ Decoding retains the selected native mapping boundaries. A group written by
168
+ its own mapping stays together; separate mappings receive a diaeresis where
169
+ their letters would otherwise be joined by a declared group mapping. For
170
+ example, with `au: AU`, `a: A` and `u: U`, the AU glyph reads _au_ and the A
171
+ then U glyphs read _aü_. Groups use `rules.vowel-groups.groups`, longest first;
172
+ there is no default group list. Existing marks and the interiors of explicit
173
+ mappings retain their spelling. Literal `⟦…⟧` spans receive no derived marks.
174
+
175
+ The native writing must distinguish the group from its split. A group with
176
+ no mapping of its own, or one emitting the same sequence as its separate
177
+ vowels, cannot restore that distinction. The compiler still accepts such an
178
+ encoding and its bare vowel spelling. An exact encoding refuses a newly
179
+ derived split input when its native path cannot recover the mark. This also
180
+ applies when a particular context or an acute prevents the distinct group
181
+ mapping from matching. Canonical encodings retain their existing longest
182
+ complete native match and authored-alias preferences, so an ambiguous split
183
+ may return the conventional bare spelling. Standalone or non-group diaeresis
184
+ aliases remain accepted, but have no rule-derived mark to restore; this is
185
+ not a pronunciation validator. An encoding that omits the permission block
186
+ or forbids the diaeresis derives no aliases or marks.
187
+
150
188
  ## Conversion API
151
189
 
152
190
  The pure engine works in browsers and Node and opens no files. The caller
@@ -6314,7 +6314,7 @@ SoHL sets `system.quantity` to `1` for each compendium item.
6314
6314
  | `sounds.marks` | `{ mark, letters, kind, limit }[]` | The marks the language writes on its letters |
6315
6315
  | `sounds.marks[].mark` | `string`, required | The mark's name, as `acute` |
6316
6316
  | `sounds.marks[].letters` | `string[]`, required | The letters that carry it |
6317
- | `sounds.marks[].kind` | `stress`, `length`, `apart` or `quality`, required | What it says of its letter |
6317
+ | `sounds.marks[].kind` | `stress`, `length`, `apart`, `quality` or `tone`, required | What it says of its letter; `tone` marks a rising or falling pitch |
6318
6318
  | `sounds.marks[].limit` | `one` or `any`, required | How many a word may carry |
6319
6319
  | `sounds.skeleton` | `{ second, third?, between? }[]` | The vowel set between a root's second and third consonants |
6320
6320
  | `sounds.skeleton[].second` | `string[]` or `any`, required | The second consonants the row holds for; `any` for every other |
@@ -6970,7 +6970,7 @@ closed list where its values are words:
6970
6970
  - `joins`: what happens where a consonant or a vowel meets another (`C+C`,
6971
6971
  `V+C`, `V+V`): `merge`, `insert` the `letters` between, `drop` the first, or
6972
6972
  `double` the consonant, everywhere or `where` stated.
6973
- - `marks`: the marks written on letters, each saying `stress`, `length`, `apart` (the mark sets a vowel to be said apart from its neighbour, as a diaeresis does) or `quality` (the mark changes which vowel is said, as a grave writing the short vowel of _cat_ does), and
6973
+ - `marks`: the marks written on letters, each saying `stress`, `length`, `apart` (the mark sets a vowel to be said apart from its neighbour, as a diaeresis does), `quality` (the mark changes which vowel is said, as a grave writing the short vowel of _cat_ does) or `tone` (marks a rising or falling pitch), and
6974
6974
  whether a word carries `one` at most or `any`.
6975
6975
  - `skeleton`: the vowel set between a root's second and third consonants, by
6976
6976
  the second consonant and, where a row states `third`, the third; nothing
@@ -8319,72 +8319,72 @@ did not foresee. Holes found in play are fixed in the scenario itself.
8319
8319
  - 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.
8320
8320
  - 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.
8321
8321
 
8322
- | `data` property | Values | Description |
8323
- | ------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
8324
- | `language` | a configured language alias | The configured language alias an encoding document writes. |
8325
- | `font` | an installed font slug | The encoding's installed font, which supplies all glyph codes. |
8326
- | `accepts` | a list of characters or ranges | Accepted Roman letters and additions to standard punctuation. |
8327
- | `vowels` | a string | The letters a contextual vowel match reads as vowels. |
8328
- | `mappings` | Roman strings to glyph codes | Roman source strings mapped to codes published by the selected font. |
8329
- | `rules` | the closed encoding rules vocabulary | Executable normalization, matching, case, stress, vowel groups, accents and contextual mappings. |
8330
- | `rules.normalization` | "NFC" | Required value: "NFC". |
8331
- | `rules.matching` | "longest-first" | Required value: "longest-first". |
8332
- | `rules.roundTrip` | a string | Exact by default; canonical permits aliases and empty code sequences and decodes an authored conventional spelling. |
8333
- | `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. |
8334
- | `rules.case` | a map | Case handling for letters and consonant groups. |
8335
- | `rules.case.single-letter` | "preserve" | Required value: "preserve". |
8336
- | `rules.case.consonant-group` | "lowercase-or-initial-uppercase" | Required value: "lowercase-or-initial-uppercase". |
8337
- | `rules.case.other-group-cases` | "forbidden" | Required value: "forbidden". |
8338
- | `rules.stress` | a map | The font's nonprinting stress modifier after an acute vowel. |
8339
- | `rules.stress.modifier` | a string | The font code for U+E0114. |
8340
- | `rules.stress.position` | "after-stressed-vowel" | Required value: "after-stressed-vowel". |
8341
- | `rules.stress.printing` | false | Required value: false. |
8342
- | `rules.stress.effect` | "stress-only" | Required value: "stress-only". |
8343
- | `rules.stress.changes-sound` | false | Required value: false. |
8344
- | `rules.stress.changes-length` | false | Required value: false. |
8345
- | `rules.stress.vowel-group-position` | "first-vowel" | Required value: "first-vowel". |
8346
- | `rules.vowel-groups` | a map | Explicit recognized vowel groups and their acute placement. |
8347
- | `rules.vowel-groups.reading` | "established" | Required value: "established". |
8348
- | `rules.vowel-groups.allow-splitting` | false | Required value: false. |
8349
- | `rules.vowel-groups.unrecognized-adjacent-vowels` | "separate" | Required value: "separate". |
8350
- | `rules.vowel-groups.groups` | a list of vowel groups | The complete authored group inventory. |
8351
- | `rules.diaeresis` | a map | Whether diaeresis mappings are allowed. |
8352
- | `rules.diaeresis.allowed` | true or false | False forbids the accent; true permits explicitly mapped and accepted characters. |
8353
- | `rules.grave-accent` | a map | Whether grave-accent mappings are allowed. |
8354
- | `rules.grave-accent.allowed` | true or false | False forbids the accent; true permits explicitly mapped and accepted characters. |
8355
- | `rules.contextual-mappings` | a list of conditional mappings | Alternatives conditioned on the following letter or source position, applied before unconditional mappings. |
8356
- | `rules.contextual-mappings[].match` | a string | The source sequence. |
8357
- | `rules.contextual-mappings[].before` | a string | Match before a declared vowel or another letter. |
8358
- | `rules.contextual-mappings[].at` | a string | Match at word start, word end or line start. |
8359
- | `rules.contextual-mappings[].emit` | a glyph code or list of codes (empty only with canonical round trips) | The font codes written by this alternative. |
8360
- | `rules.y` | a map | The consonant reading of y. |
8361
- | `rules.y.kind` | "consonant" | Required value: "consonant". |
8362
- | `rules.y.sound` | "y in yes" | Required value: "y in yes". |
8363
- | `rules.y.vowel-reading` | false | Required value: false. |
8364
- | `culture` | `Address` | `settingguide` only, and required there: the culture the guide introduces, naming a lore note with `subType: culture` |
8365
- | `concepts` | list of `{ id, gloss, group }` | A core vocabulary's concept list, any subType. See [the core vocabulary](#the-core-vocabulary) |
8366
- | `date` | `YYYY-MM-DD` | `post` only, and required there: the day the post was published |
8367
- | `author` | string | `post` only: who wrote the post; a post naming none carries the site's byline, the first entry of `authors` |
8368
- | `concepts[].id` | string | Required: lowercase letters, digits and `-`, unique in the package |
8369
- | `concepts[].gloss` | string | Required: what the concept means, in brief |
8370
- | `concepts[].group` | string | Required: the group the report lists the concept under |
8371
- | `metres` | `{ name, beats, kind }[]` | The metres a verse div's `scan=` names; any subType. See [metres](#metres) |
8372
- | `metres[].name` | string | Required: what `scan=` names it by, lowercase letters, digits and `-`, unique in the package |
8373
- | `metres[].beats` | `integer`, required | The beats of one line, above zero |
8374
- | `metres[].kind` | `accentual` | Required: how the beats are counted |
8375
- | `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) |
8376
- | `sounds.letters` | `{ letters, kind, like?, place?, ipa?, tongue? }[]` | The letters, each row's keys as a language note's `sounds.letters` |
8377
- | `sounds.letters[].letters` | `string[]`, required | The letters or digraphs the row describes |
8378
- | `sounds.letters[].kind` | `string`, required | What kind of sound they write, as `consonant` or `vowel pair` |
8379
- | `sounds.letters[].like` | `string` | What they sound like |
8380
- | `sounds.letters[].place` | `string` | Where in the mouth they are made |
8381
- | `sounds.letters[].ipa` | `string` | The sounds they write, in the International Phonetic Alphabet |
8382
- | `sounds.letters[].tongue` | an Address, or a list of them | The language notes that write the letters; every tongue where absent |
8383
- | `sounds.marks` | `{ mark, letters, kind, limit }[]` | The marks written on the letters, each row's keys as a language note's `sounds.marks` |
8384
- | `sounds.marks[].mark` | `string`, required | The mark's name, as `acute` |
8385
- | `sounds.marks[].letters` | `string[]`, required | The letters that carry it |
8386
- | `sounds.marks[].kind` | `stress`, `length`, `apart` or `quality`, required | What it says of its letter |
8387
- | `sounds.marks[].limit` | `one` or `any`, required | How many a word may carry |
8322
+ | `data` property | Values | Description |
8323
+ | ------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
8324
+ | `language` | a configured language alias | The configured language alias an encoding document writes. |
8325
+ | `font` | an installed font slug | The encoding's installed font, which supplies all glyph codes. |
8326
+ | `accepts` | a list of characters or ranges | Accepted Roman letters and additions to standard punctuation. |
8327
+ | `vowels` | a string | The letters a contextual vowel match reads as vowels. |
8328
+ | `mappings` | Roman strings to glyph codes | Roman source strings mapped to codes published by the selected font. |
8329
+ | `rules` | the closed encoding rules vocabulary | Executable normalization, matching, case, stress, vowel groups, accents and contextual mappings. |
8330
+ | `rules.normalization` | "NFC" | Required value: "NFC". |
8331
+ | `rules.matching` | "longest-first" | Required value: "longest-first". |
8332
+ | `rules.roundTrip` | a string | Exact by default; canonical permits aliases and empty code sequences and decodes an authored conventional spelling. |
8333
+ | `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. |
8334
+ | `rules.case` | a map | Case handling for letters and consonant groups. |
8335
+ | `rules.case.single-letter` | "preserve" | Required value: "preserve". |
8336
+ | `rules.case.consonant-group` | "lowercase-or-initial-uppercase" | Required value: "lowercase-or-initial-uppercase". |
8337
+ | `rules.case.other-group-cases` | "forbidden" | Required value: "forbidden". |
8338
+ | `rules.stress` | a map | The font's nonprinting stress modifier after an acute vowel. |
8339
+ | `rules.stress.modifier` | a string | The font code for U+E0114. |
8340
+ | `rules.stress.position` | "after-stressed-vowel" | Required value: "after-stressed-vowel". |
8341
+ | `rules.stress.printing` | false | Required value: false. |
8342
+ | `rules.stress.effect` | "stress-only" | Required value: "stress-only". |
8343
+ | `rules.stress.changes-sound` | false | Required value: false. |
8344
+ | `rules.stress.changes-length` | false | Required value: false. |
8345
+ | `rules.stress.vowel-group-position` | "first-vowel" | Required value: "first-vowel". |
8346
+ | `rules.vowel-groups` | a map | Explicit recognized vowel groups and their acute placement. |
8347
+ | `rules.vowel-groups.reading` | "established" | Required value: "established". |
8348
+ | `rules.vowel-groups.allow-splitting` | false | Required value: false. |
8349
+ | `rules.vowel-groups.unrecognized-adjacent-vowels` | "separate" | Required value: "separate". |
8350
+ | `rules.vowel-groups.groups` | a list of vowel groups | The complete authored group inventory. |
8351
+ | `rules.diaeresis` | a map | Whether diaeresis vowels are allowed, and how they are written. |
8352
+ | `rules.diaeresis.allowed` | true or false | False forbids the diaeresis; true derives plain-vowel mappings and restores splits where a declared group's native mapping distinguishes joined and separate vowels. Exact conversion refuses an unrecoverable derived group split; canonical conversion keeps its conventional reading. |
8353
+ | `rules.grave-accent` | a map | Whether grave-accent mappings are allowed. |
8354
+ | `rules.grave-accent.allowed` | true or false | False forbids the accent; true permits explicitly mapped and accepted characters. |
8355
+ | `rules.contextual-mappings` | a list of conditional mappings | Alternatives conditioned on the following letter or source position, applied before unconditional mappings. |
8356
+ | `rules.contextual-mappings[].match` | a string | The source sequence. |
8357
+ | `rules.contextual-mappings[].before` | a string | Match before a declared vowel or another letter. |
8358
+ | `rules.contextual-mappings[].at` | a string | Match at word start, word end or line start. |
8359
+ | `rules.contextual-mappings[].emit` | a glyph code or list of codes (empty only with canonical round trips) | The font codes written by this alternative. |
8360
+ | `rules.y` | a map | The consonant reading of y. |
8361
+ | `rules.y.kind` | "consonant" | Required value: "consonant". |
8362
+ | `rules.y.sound` | "y in yes" | Required value: "y in yes". |
8363
+ | `rules.y.vowel-reading` | false | Required value: false. |
8364
+ | `culture` | `Address` | `settingguide` only, and required there: the culture the guide introduces, naming a lore note with `subType: culture` |
8365
+ | `concepts` | list of `{ id, gloss, group }` | A core vocabulary's concept list, any subType. See [the core vocabulary](#the-core-vocabulary) |
8366
+ | `date` | `YYYY-MM-DD` | `post` only, and required there: the day the post was published |
8367
+ | `author` | string | `post` only: who wrote the post; a post naming none carries the site's byline, the first entry of `authors` |
8368
+ | `concepts[].id` | string | Required: lowercase letters, digits and `-`, unique in the package |
8369
+ | `concepts[].gloss` | string | Required: what the concept means, in brief |
8370
+ | `concepts[].group` | string | Required: the group the report lists the concept under |
8371
+ | `metres` | `{ name, beats, kind }[]` | The metres a verse div's `scan=` names; any subType. See [metres](#metres) |
8372
+ | `metres[].name` | string | Required: what `scan=` names it by, lowercase letters, digits and `-`, unique in the package |
8373
+ | `metres[].beats` | `integer`, required | The beats of one line, above zero |
8374
+ | `metres[].kind` | `accentual` | Required: how the beats are counted |
8375
+ | `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) |
8376
+ | `sounds.letters` | `{ letters, kind, like?, place?, ipa?, tongue? }[]` | The letters, each row's keys as a language note's `sounds.letters` |
8377
+ | `sounds.letters[].letters` | `string[]`, required | The letters or digraphs the row describes |
8378
+ | `sounds.letters[].kind` | `string`, required | What kind of sound they write, as `consonant` or `vowel pair` |
8379
+ | `sounds.letters[].like` | `string` | What they sound like |
8380
+ | `sounds.letters[].place` | `string` | Where in the mouth they are made |
8381
+ | `sounds.letters[].ipa` | `string` | The sounds they write, in the International Phonetic Alphabet |
8382
+ | `sounds.letters[].tongue` | an Address, or a list of them | The language notes that write the letters; every tongue where absent |
8383
+ | `sounds.marks` | `{ mark, letters, kind, limit }[]` | The marks written on the letters, each row's keys as a language note's `sounds.marks` |
8384
+ | `sounds.marks[].mark` | `string`, required | The mark's name, as `acute` |
8385
+ | `sounds.marks[].letters` | `string[]`, required | The letters that carry it |
8386
+ | `sounds.marks[].kind` | `stress`, `length`, `apart`, `quality` or `tone`, required | What it says of its letter; `tone` marks a rising or falling pitch |
8387
+ | `sounds.marks[].limit` | `one` or `any`, required | How many a word may carry |
8388
8388
 
8389
8389
  A setting guide introduces one culture's view of the setting, so it names that
8390
8390
  culture, and a `doc` of any other `subType` has no culture of its own to name.
@@ -636,7 +636,7 @@ These fields are accepted by every note type. A field's value can still be irrel
636
636
  | `data.sounds.marks` | list of `{ mark, letters, kind, limit }` | no | The marks the language writes on its letters. |
637
637
  | `data.sounds.marks[].mark` | a string, such as `acute` | yes | The mark's name. |
638
638
  | `data.sounds.marks[].letters` | a list of letters | yes | The letters that carry it. |
639
- | `data.sounds.marks[].kind` | one of stress, length, apart, quality | yes | What the mark says of its letter. |
639
+ | `data.sounds.marks[].kind` | one of stress, length, apart, quality, tone | yes | What the mark says of its letter. |
640
640
  | `data.sounds.marks[].limit` | one of one, any | yes | How many a word may carry: `one` at most, or `any`. |
641
641
  | `data.sounds.skeleton` | list of `{ second, between? }` | no | The vowel set between a root's second and third consonants. |
642
642
  | `data.sounds.skeleton[].second` | a list of sounds, or `any` | yes | The second consonants of a root the row holds for; `any` for every other. |
@@ -1027,60 +1027,60 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
1027
1027
 
1028
1028
  **Inner keys** — a key a field's value does not declare is an error at its own line and column.
1029
1029
 
1030
- | Key | Shape | Required | Meaning |
1031
- | ------------------------------------------------------ | --------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1032
- | `data.rules.normalization` | "NFC" | yes | Required value: "NFC". |
1033
- | `data.rules.matching` | "longest-first" | yes | Required value: "longest-first". |
1034
- | `data.rules.roundTrip` | a string | no | Exact by default; canonical permits aliases and empty code sequences and decodes an authored conventional spelling. |
1035
- | `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. |
1036
- | `data.rules.case` | a map | no | Case handling for letters and consonant groups. |
1037
- | `data.rules.case.single-letter` | "preserve" | yes | Required value: "preserve". |
1038
- | `data.rules.case.consonant-group` | "lowercase-or-initial-uppercase" | yes | Required value: "lowercase-or-initial-uppercase". |
1039
- | `data.rules.case.other-group-cases` | "forbidden" | yes | Required value: "forbidden". |
1040
- | `data.rules.stress` | a map | no | The font's nonprinting stress modifier after an acute vowel. |
1041
- | `data.rules.stress.modifier` | a string | yes | The font code for U+E0114. |
1042
- | `data.rules.stress.position` | "after-stressed-vowel" | yes | Required value: "after-stressed-vowel". |
1043
- | `data.rules.stress.printing` | false | yes | Required value: false. |
1044
- | `data.rules.stress.effect` | "stress-only" | yes | Required value: "stress-only". |
1045
- | `data.rules.stress.changes-sound` | false | yes | Required value: false. |
1046
- | `data.rules.stress.changes-length` | false | yes | Required value: false. |
1047
- | `data.rules.stress.vowel-group-position` | "first-vowel" | yes | Required value: "first-vowel". |
1048
- | `data.rules.vowel-groups` | a map | no | Explicit recognized vowel groups and their acute placement. |
1049
- | `data.rules.vowel-groups.reading` | "established" | yes | Required value: "established". |
1050
- | `data.rules.vowel-groups.allow-splitting` | false | yes | Required value: false. |
1051
- | `data.rules.vowel-groups.unrecognized-adjacent-vowels` | "separate" | yes | Required value: "separate". |
1052
- | `data.rules.vowel-groups.groups` | a list of vowel groups | yes | The complete authored group inventory. |
1053
- | `data.rules.diaeresis` | a map | no | Whether diaeresis mappings are allowed. |
1054
- | `data.rules.diaeresis.allowed` | true or false | yes | False forbids the accent; true permits explicitly mapped and accepted characters. |
1055
- | `data.rules.grave-accent` | a map | no | Whether grave-accent mappings are allowed. |
1056
- | `data.rules.grave-accent.allowed` | true or false | yes | False forbids the accent; true permits explicitly mapped and accepted characters. |
1057
- | `data.rules.contextual-mappings` | a list of conditional mappings | no | Alternatives conditioned on the following letter or source position, applied before unconditional mappings. |
1058
- | `data.rules.contextual-mappings[].match` | a string | yes | The source sequence. |
1059
- | `data.rules.contextual-mappings[].before` | a string | no | Match before a declared vowel or another letter. |
1060
- | `data.rules.contextual-mappings[].at` | a string | no | Match at word start, word end or line start. |
1061
- | `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. |
1062
- | `data.rules.y` | a map | no | The consonant reading of y. |
1063
- | `data.rules.y.kind` | "consonant" | yes | Required value: "consonant". |
1064
- | `data.rules.y.sound` | "y in yes" | yes | Required value: "y in yes". |
1065
- | `data.rules.y.vowel-reading` | false | yes | Required value: false. |
1066
- | `data.concepts[].id` | lowercase letters, digits and `-` | yes | The concept's id, which a word's `concept` names. Unique in the package. |
1067
- | `data.concepts[].gloss` | a short gloss, such as `a smell; a scent` | yes | What the concept means, in brief. |
1068
- | `data.concepts[].group` | a group name, such as `senses` | yes | The group the report lists the concept under. |
1069
- | `data.sounds.letters` | list of `{ letters, kind, like?, place?, ipa?, tongue? }` | no | The letters the language writes, and the sounds they stand for. |
1070
- | `data.sounds.letters[].letters` | a list of letters | yes | The letters or digraphs the row describes, as written. |
1071
- | `data.sounds.letters[].kind` | a string, such as `consonant` or `long vowel` | yes | What kind of sound they write. |
1072
- | `data.sounds.letters[].like` | a string | no | What they sound like; absent where the row states only their kind and place. |
1073
- | `data.sounds.letters[].place` | a string, such as `lips` or `tongue-tip` | no | Where in the mouth they are made. |
1074
- | `data.sounds.letters[].ipa` | a string in the International Phonetic Alphabet, such as `ʃ` | no | The sounds they write, in the International Phonetic Alphabet. |
1075
- | `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. |
1076
- | `data.sounds.marks` | list of `{ mark, letters, kind, limit }` | no | The marks the language writes on its letters. |
1077
- | `data.sounds.marks[].mark` | a string, such as `acute` | yes | The mark's name. |
1078
- | `data.sounds.marks[].letters` | a list of letters | yes | The letters that carry it. |
1079
- | `data.sounds.marks[].kind` | one of stress, length, apart, quality | yes | What the mark says of its letter. |
1080
- | `data.sounds.marks[].limit` | one of one, any | yes | How many a word may carry: `one` at most, or `any`. |
1081
- | `data.metres[].name` | string | yes | What a verse div's `scan=` names the metre by: lowercase letters, digits and `-`, unique in the package. |
1082
- | `data.metres[].beats` | a whole number above zero | yes | The beats of one line. |
1083
- | `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. |
1030
+ | Key | Shape | Required | Meaning |
1031
+ | ------------------------------------------------------ | --------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1032
+ | `data.rules.normalization` | "NFC" | yes | Required value: "NFC". |
1033
+ | `data.rules.matching` | "longest-first" | yes | Required value: "longest-first". |
1034
+ | `data.rules.roundTrip` | a string | no | Exact by default; canonical permits aliases and empty code sequences and decodes an authored conventional spelling. |
1035
+ | `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. |
1036
+ | `data.rules.case` | a map | no | Case handling for letters and consonant groups. |
1037
+ | `data.rules.case.single-letter` | "preserve" | yes | Required value: "preserve". |
1038
+ | `data.rules.case.consonant-group` | "lowercase-or-initial-uppercase" | yes | Required value: "lowercase-or-initial-uppercase". |
1039
+ | `data.rules.case.other-group-cases` | "forbidden" | yes | Required value: "forbidden". |
1040
+ | `data.rules.stress` | a map | no | The font's nonprinting stress modifier after an acute vowel. |
1041
+ | `data.rules.stress.modifier` | a string | yes | The font code for U+E0114. |
1042
+ | `data.rules.stress.position` | "after-stressed-vowel" | yes | Required value: "after-stressed-vowel". |
1043
+ | `data.rules.stress.printing` | false | yes | Required value: false. |
1044
+ | `data.rules.stress.effect` | "stress-only" | yes | Required value: "stress-only". |
1045
+ | `data.rules.stress.changes-sound` | false | yes | Required value: false. |
1046
+ | `data.rules.stress.changes-length` | false | yes | Required value: false. |
1047
+ | `data.rules.stress.vowel-group-position` | "first-vowel" | yes | Required value: "first-vowel". |
1048
+ | `data.rules.vowel-groups` | a map | no | Explicit recognized vowel groups and their acute placement. |
1049
+ | `data.rules.vowel-groups.reading` | "established" | yes | Required value: "established". |
1050
+ | `data.rules.vowel-groups.allow-splitting` | false | yes | Required value: false. |
1051
+ | `data.rules.vowel-groups.unrecognized-adjacent-vowels` | "separate" | yes | Required value: "separate". |
1052
+ | `data.rules.vowel-groups.groups` | a list of vowel groups | yes | The complete authored group inventory. |
1053
+ | `data.rules.diaeresis` | a map | no | Whether diaeresis vowels are allowed, and how they are written. |
1054
+ | `data.rules.diaeresis.allowed` | true or false | yes | False forbids the diaeresis; true derives plain-vowel mappings and restores splits where a declared group's native mapping distinguishes joined and separate vowels. Exact conversion refuses an unrecoverable derived group split; canonical conversion keeps its conventional reading. |
1055
+ | `data.rules.grave-accent` | a map | no | Whether grave-accent mappings are allowed. |
1056
+ | `data.rules.grave-accent.allowed` | true or false | yes | False forbids the accent; true permits explicitly mapped and accepted characters. |
1057
+ | `data.rules.contextual-mappings` | a list of conditional mappings | no | Alternatives conditioned on the following letter or source position, applied before unconditional mappings. |
1058
+ | `data.rules.contextual-mappings[].match` | a string | yes | The source sequence. |
1059
+ | `data.rules.contextual-mappings[].before` | a string | no | Match before a declared vowel or another letter. |
1060
+ | `data.rules.contextual-mappings[].at` | a string | no | Match at word start, word end or line start. |
1061
+ | `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. |
1062
+ | `data.rules.y` | a map | no | The consonant reading of y. |
1063
+ | `data.rules.y.kind` | "consonant" | yes | Required value: "consonant". |
1064
+ | `data.rules.y.sound` | "y in yes" | yes | Required value: "y in yes". |
1065
+ | `data.rules.y.vowel-reading` | false | yes | Required value: false. |
1066
+ | `data.concepts[].id` | lowercase letters, digits and `-` | yes | The concept's id, which a word's `concept` names. Unique in the package. |
1067
+ | `data.concepts[].gloss` | a short gloss, such as `a smell; a scent` | yes | What the concept means, in brief. |
1068
+ | `data.concepts[].group` | a group name, such as `senses` | yes | The group the report lists the concept under. |
1069
+ | `data.sounds.letters` | list of `{ letters, kind, like?, place?, ipa?, tongue? }` | no | The letters the language writes, and the sounds they stand for. |
1070
+ | `data.sounds.letters[].letters` | a list of letters | yes | The letters or digraphs the row describes, as written. |
1071
+ | `data.sounds.letters[].kind` | a string, such as `consonant` or `long vowel` | yes | What kind of sound they write. |
1072
+ | `data.sounds.letters[].like` | a string | no | What they sound like; absent where the row states only their kind and place. |
1073
+ | `data.sounds.letters[].place` | a string, such as `lips` or `tongue-tip` | no | Where in the mouth they are made. |
1074
+ | `data.sounds.letters[].ipa` | a string in the International Phonetic Alphabet, such as `ʃ` | no | The sounds they write, in the International Phonetic Alphabet. |
1075
+ | `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. |
1076
+ | `data.sounds.marks` | list of `{ mark, letters, kind, limit }` | no | The marks the language writes on its letters. |
1077
+ | `data.sounds.marks[].mark` | a string, such as `acute` | yes | The mark's name. |
1078
+ | `data.sounds.marks[].letters` | a list of letters | yes | The letters that carry it. |
1079
+ | `data.sounds.marks[].kind` | one of stress, length, apart, quality, tone | yes | What the mark says of its letter. |
1080
+ | `data.sounds.marks[].limit` | one of one, any | yes | How many a word may carry: `one` at most, or `any`. |
1081
+ | `data.metres[].name` | string | yes | What a verse div's `scan=` names the metre by: lowercase letters, digits and `-`, unique in the package. |
1082
+ | `data.metres[].beats` | a whole number above zero | yes | The beats of one line. |
1083
+ | `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. |
1084
1084
 
1085
1085
  ### macro
1086
1086
 
@@ -21,6 +21,12 @@ const scalar = (value) =>
21
21
  !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
22
22
  const base = (value) => value.normalize("NFD").replace(/\p{M}/gu, "");
23
23
  const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
24
+ /** Each diaeresis vowel and the plain vowel whose mapping writes it. */
25
+ const DIAERESIS_VOWELS = Object.freeze(
26
+ Object.fromEntries(
27
+ [..."aeiouAEIOU"].map((plain) => [(plain + "\u0308").normalize("NFC"), plain]),
28
+ ),
29
+ );
24
30
  const codeName = /^[A-Za-z0-9_]+$/;
25
31
 
26
32
  /**
@@ -339,12 +345,27 @@ export function templateProgram(data, options, fault) {
339
345
  ]),
340
346
  ];
341
347
  if (!Array.isArray(accepts)) fault("accepts", "accepts must be a list of characters or ranges");
348
+ // A diaeresis vowel no source names is written with its plain vowel's rows,
349
+ // and decoding restores it by rule.
350
+ const named = [...mappings.map(([from]) => from), ...contextual.map((r) => r.from)];
351
+ const derived =
352
+ rules.diaeresis?.allowed === true ?
353
+ Object.fromEntries(
354
+ Object.entries(DIAERESIS_VOWELS).filter(
355
+ ([letter, plain]) =>
356
+ named.includes(plain) &&
357
+ !named.some((from) => from.includes(letter)) &&
358
+ !separators.some((row) => row.from.includes(letter)),
359
+ ),
360
+ )
361
+ : undefined;
342
362
  return {
343
363
  definition: {
344
364
  name: "encoding-note",
345
365
  fonts: [data.font],
346
366
  accepts: [
347
367
  ...accepts,
368
+ ...Object.keys(derived ?? {}),
348
369
  ...STANDARD_ROWS.flatMap((r) => [...(Array.isArray(r) ? r[0] : r.from)]),
349
370
  ],
350
371
  separator: " ",
@@ -366,6 +387,13 @@ export function templateProgram(data, options, fault) {
366
387
  maxVowelGroup: Math.max(0, ...vowelGroups.map((group) => [...group].length)),
367
388
  stress,
368
389
  diaeresis: rules.diaeresis?.allowed === false,
390
+ apart:
391
+ derived ?
392
+ Object.freeze({
393
+ letters: Object.freeze(derived),
394
+ groups: Object.freeze(vowelGroups),
395
+ })
396
+ : undefined,
369
397
  grave: rules["grave-accent"]?.allowed === false,
370
398
  }),
371
399
  },
@@ -89,18 +89,26 @@ export const ENCODING_RULE_FIELDS = Object.freeze([
89
89
  describe: "The complete authored group inventory.",
90
90
  },
91
91
  ]),
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
- ),
92
+ block("diaeresis", "Whether diaeresis vowels are allowed, and how they are written.", [
93
+ {
94
+ name: "allowed",
95
+ kind: "boolean",
96
+ shape: "true or false",
97
+ required: true,
98
+ describe:
99
+ "False forbids the diaeresis; true derives plain-vowel mappings and restores splits where a declared group's native mapping distinguishes joined and separate vowels. Exact conversion refuses an unrecoverable derived group split; canonical conversion keeps its conventional reading.",
100
+ },
101
+ ]),
102
+ block("grave-accent", "Whether grave-accent mappings are allowed.", [
103
+ {
104
+ name: "allowed",
105
+ kind: "boolean",
106
+ shape: "true or false",
107
+ required: true,
108
+ describe:
109
+ "False forbids the accent; true permits explicitly mapped and accepted characters.",
110
+ },
111
+ ]),
104
112
  {
105
113
  name: "contextual-mappings",
106
114
  kind: "list",
@@ -470,7 +470,11 @@ function checkGroupCase(enc, groups, text, at, bound) {
470
470
  * @param {string} text
471
471
  * @param {{font?: string, verse?: boolean, start?: number, end?: number}} [options]
472
472
  */
473
- function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text.length } = {}) {
473
+ function encodeRawPieces(
474
+ enc,
475
+ text,
476
+ { font, verse = false, start = 0, end = text.length, stops = [] } = {},
477
+ ) {
474
478
  const groups = tiers(enc, font);
475
479
  if (typeof text !== "string") throw new EncodingError("Text must be a string");
476
480
  if (
@@ -485,9 +489,13 @@ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text
485
489
  });
486
490
  const bound = end;
487
491
  const out = [];
488
- let at = start;
492
+ let at = start,
493
+ stop = 0;
489
494
  while (at < bound) {
490
495
  const first = firstAt(text, at);
496
+ while (stop < stops.length && stops[stop] <= at) stop++;
497
+ // A row never spans the start of a diaeresis vowel written by its plain vowel.
498
+ const limit = Math.min(bound, stops[stop] ?? bound);
491
499
  if (first === OPEN) {
492
500
  const close = span(text.slice(0, bound), at, OPEN, CLOSE);
493
501
  out.push(
@@ -545,7 +553,7 @@ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text
545
553
  continue;
546
554
  }
547
555
  if (!enc.template) checkGroupCase(enc, groups, text, at, bound);
548
- const row = enc.accepted.has(first) ? rowAt(enc, groups, text, at, bound) : null;
556
+ const row = enc.accepted.has(first) ? rowAt(enc, groups, text, at, limit) : null;
549
557
  if (!row)
550
558
  refused(
551
559
  enc,
@@ -688,7 +696,13 @@ function sourceDetails(enc, text, options) {
688
696
  pointSlice(normalized.slice(0, bound), at, enc.template.maxVowelGroup)
689
697
  : "";
690
698
  const lower = lookahead.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
691
- const group = enc.template?.vowelGroups.find((value) => lower.startsWith(value));
699
+ const group = enc.template?.vowelGroups.find(
700
+ (value) =>
701
+ lower.startsWith(value) &&
702
+ [...lookahead]
703
+ .slice(1, [...value].length)
704
+ .every((letter) => !/[\u0308\u0300]/u.test(letter.normalize("NFD"))),
705
+ );
692
706
  if (group) {
693
707
  let cursor = at;
694
708
  for (let i = 0; i < [...group].length; i++) {
@@ -713,7 +727,16 @@ function sourceDetails(enc, text, options) {
713
727
  });
714
728
  throw error;
715
729
  }
716
- return projection;
730
+ const letters = enc.template?.apart?.letters;
731
+ if (!letters) return { ...projection, folded: normalized, stops: [] };
732
+ const stops = [];
733
+ let folded = "";
734
+ for (let at = 0; at < normalized.length; at++) {
735
+ const plain = at >= begin && at < bound ? letters[normalized[at]] : undefined;
736
+ if (plain) stops.push(at);
737
+ folded += plain ?? normalized[at];
738
+ }
739
+ return { ...projection, folded, stops };
717
740
  }
718
741
 
719
742
  /**
@@ -736,16 +759,49 @@ export function validateSource(enc, text, options = {}) {
736
759
  */
737
760
  export function encodePieces(enc, text, options = {}) {
738
761
  if (!enc?.template) return encodeRawPieces(enc, text, options);
739
- const { normalized, sourceAt, begin, bound } = sourceDetails(enc, text, options);
762
+ const { normalized, folded, stops, sourceAt, begin, bound } = sourceDetails(enc, text, options);
740
763
  try {
741
- return encodeRawPieces(enc, normalized, { ...options, start: begin, end: bound }).map(
742
- (one) => ({
743
- ...one,
744
- from: text.slice(sourceAt[one.start], sourceAt[one.end]),
745
- start: sourceAt[one.start],
746
- end: sourceAt[one.end],
747
- }),
748
- );
764
+ const pieces = encodeRawPieces(enc, folded, {
765
+ ...options,
766
+ start: begin,
767
+ end: bound,
768
+ stops,
769
+ });
770
+ if (enc.roundTrip === "exact" && stops.length) {
771
+ const native = pieces.map((one) => one.to).join("");
772
+ const decoded = decodeTemplatePieces(enc, native, options);
773
+ let nativeAt = 0;
774
+ for (const one of pieces) {
775
+ const expected = normalized.slice(one.start, one.end);
776
+ const marked = stops.find(
777
+ (at) =>
778
+ at >= one.start &&
779
+ at < one.end &&
780
+ splitsDeclaredGroup(enc, normalized, at, begin, bound),
781
+ );
782
+ if (
783
+ marked !== undefined &&
784
+ !decoded.some(
785
+ (step) =>
786
+ step.start === nativeAt &&
787
+ step.end === nativeAt + one.to.length &&
788
+ step.from === expected,
789
+ )
790
+ )
791
+ textError(
792
+ folded,
793
+ marked,
794
+ "The encoding cannot distinguish this diaeresis split in an exact round trip",
795
+ );
796
+ nativeAt += one.to.length;
797
+ }
798
+ }
799
+ return pieces.map((one) => ({
800
+ ...one,
801
+ from: text.slice(sourceAt[one.start], sourceAt[one.end]),
802
+ start: sourceAt[one.start],
803
+ end: sourceAt[one.end],
804
+ }));
749
805
  } catch (error) {
750
806
  if (error instanceof EncodingError && error.offset !== undefined)
751
807
  throw new EncodingError(error.message, {
@@ -845,6 +901,12 @@ function decodeRawText(enc, text, { font } = {}) {
845
901
  */
846
902
  export function decodeText(enc, text, options = {}) {
847
903
  if (!enc?.template) return decodeRawText(enc, text, options);
904
+ return decodeTemplatePieces(enc, text, options)
905
+ .map((one) => one.from)
906
+ .join("");
907
+ }
908
+
909
+ function decodeTemplatePieces(enc, text, options = {}) {
848
910
  const readings = tiers(enc, options.font)[0].readings;
849
911
  if (typeof text !== "string") throw new EncodingError("Text must be a string");
850
912
  if (!templateReaders.has(enc)) {
@@ -905,7 +967,7 @@ export function decodeText(enc, text, options = {}) {
905
967
  failedContext = Math.min(failedContext, at);
906
968
  continue;
907
969
  }
908
- paths.set(at, { from: row.from, next });
970
+ paths.set(at, { from: row.from, row, next });
909
971
  }
910
972
  }
911
973
  if (!paths.has(0)) {
@@ -950,8 +1012,76 @@ export function decodeText(enc, text, options = {}) {
950
1012
  const out = [];
951
1013
  for (let at = 0; at < text.length;) {
952
1014
  const step = paths.get(at);
953
- out.push(step.from);
1015
+ out.push({ from: step.from, row: step.row, start: at, end: step.next });
954
1016
  at = step.next;
955
1017
  }
956
- return out.join("");
1018
+ const apart = enc.template.apart;
1019
+ if (!apart) return out;
1020
+ return restoreDiaeresis(enc, out, options);
1021
+ }
1022
+
1023
+ /** Restore only joins contradicted by the selected native row boundaries. */
1024
+ function restoreDiaeresis(enc, pieces, options) {
1025
+ const text = pieces.map((one) => one.from).join("");
1026
+ const letters = [];
1027
+ let offset = 0;
1028
+ for (const [part, piece] of pieces.entries()) {
1029
+ for (const char of piece.from) {
1030
+ letters.push({ char, part, offset, literal: !piece.row });
1031
+ offset += char.length;
1032
+ }
1033
+ }
1034
+ const groups = tiers(enc, options.font);
1035
+ for (let i = 0; i < letters.length; i++) {
1036
+ if (letters[i].literal) continue;
1037
+ const group = enc.template.apart.groups.find((value) => {
1038
+ const span = [...value];
1039
+ if (
1040
+ !span.every((char, k) => {
1041
+ const letter = letters[i + k];
1042
+ return (
1043
+ letter &&
1044
+ !letter.literal &&
1045
+ letter.char.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase() ===
1046
+ char &&
1047
+ (k === 0 || letter.char.normalize("NFD").length === 1)
1048
+ );
1049
+ })
1050
+ )
1051
+ return false;
1052
+ const row = rowAt(enc, groups, text, letters[i].offset);
1053
+ return row && row.from.toLowerCase() === value;
1054
+ });
1055
+ if (!group) continue;
1056
+ const end = i + [...group].length;
1057
+ let boundary = i + 1;
1058
+ while (boundary < end && letters[boundary].part === letters[i].part) boundary++;
1059
+ if (boundary === end) {
1060
+ i = end - 1;
1061
+ continue;
1062
+ }
1063
+ const letter = letters[boundary];
1064
+ const marked = (letter.char + "\u0308").normalize("NFC");
1065
+ if (enc.template.apart.letters[marked]) {
1066
+ letter.char = marked;
1067
+ i = boundary - 1;
1068
+ }
1069
+ }
1070
+ const output = pieces.map((piece) => ({ ...piece, from: "" }));
1071
+ for (const letter of letters) output[letter.part].from += letter.char;
1072
+ return output;
1073
+ }
1074
+
1075
+ /** Whether a derived mark actually splits one of this note's declared groups. */
1076
+ function splitsDeclaredGroup(enc, text, at, begin, bound) {
1077
+ for (const group of enc.template.apart.groups) {
1078
+ const count = [...group].length;
1079
+ // The supported diaeresis letters are one NFC UTF-16 unit each.
1080
+ for (let start = Math.max(begin, at - count + 1); start < at; start++) {
1081
+ const span = pointSlice(text.slice(0, bound), start, count);
1082
+ if (start + span.length <= at) continue;
1083
+ if (span.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase() === group) return true;
1084
+ }
1085
+ }
1086
+ return false;
957
1087
  }
@@ -154,10 +154,10 @@ export const JOIN_RULES = Object.freeze(["merge", "insert", "drop", "double"]);
154
154
 
155
155
  /**
156
156
  * What a written mark says of its letter: `stress` falls on it, `length` holds
157
- * it longer, `apart` says it apart from its neighbour, and `quality` changes
158
- * which vowel is said.
157
+ * it longer, `apart` says it apart from its neighbour, `quality` changes
158
+ * which vowel is said, and `tone` marks a rising or falling pitch.
159
159
  */
160
- export const MARK_KINDS = Object.freeze(["stress", "length", "apart", "quality"]);
160
+ export const MARK_KINDS = Object.freeze(["stress", "length", "apart", "quality", "tone"]);
161
161
 
162
162
  /** How many of a mark a word may carry: `one` at most, or `any` number. */
163
163
  export const MARK_LIMITS = Object.freeze(["one", "any"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.6.0",
3
+ "version": "25.6.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",
@@ -261,6 +261,12 @@ export type RunContext = {
261
261
  maxVowelGroup: number;
262
262
  stress: "󠄔" | undefined;
263
263
  diaeresis: boolean;
264
+ apart: Readonly<{
265
+ letters: Readonly<{
266
+ [k: string]: string;
267
+ }>;
268
+ groups: any;
269
+ }> | undefined;
264
270
  grave: boolean;
265
271
  }> | undefined;
266
272
  fonts: readonly any[];
@@ -90,6 +90,12 @@ export function encodingFor(ctx: import("./content-scripts.mjs").RunContext, { e
90
90
  maxVowelGroup: number;
91
91
  stress: "󠄔" | undefined;
92
92
  diaeresis: boolean;
93
+ apart: Readonly<{
94
+ letters: Readonly<{
95
+ [k: string]: string;
96
+ }>;
97
+ groups: any;
98
+ }> | undefined;
93
99
  grave: boolean;
94
100
  }> | undefined;
95
101
  fonts: readonly any[];
@@ -38,6 +38,12 @@ export function templateProgram(data: any, options: {
38
38
  maxVowelGroup: number;
39
39
  stress: "󠄔" | undefined;
40
40
  diaeresis: boolean;
41
+ apart: Readonly<{
42
+ letters: Readonly<{
43
+ [k: string]: string;
44
+ }>;
45
+ groups: any;
46
+ }> | undefined;
41
47
  grave: boolean;
42
48
  }>;
43
49
  };
@@ -16,6 +16,12 @@ export function compileEncoding(data: any, options?: {
16
16
  maxVowelGroup: number;
17
17
  stress: "󠄔" | undefined;
18
18
  diaeresis: boolean;
19
+ apart: Readonly<{
20
+ letters: Readonly<{
21
+ [k: string]: string;
22
+ }>;
23
+ groups: any;
24
+ }> | undefined;
19
25
  grave: boolean;
20
26
  }> | undefined;
21
27
  fonts: readonly any[];
@@ -59,6 +65,12 @@ export function compileRows(definition: any, metadata?: Partial<ReturnType<typeo
59
65
  maxVowelGroup: number;
60
66
  stress: "󠄔" | undefined;
61
67
  diaeresis: boolean;
68
+ apart: Readonly<{
69
+ letters: Readonly<{
70
+ [k: string]: string;
71
+ }>;
72
+ groups: any;
73
+ }> | undefined;
62
74
  grave: boolean;
63
75
  }> | undefined;
64
76
  fonts: readonly any[];
@@ -96,8 +96,8 @@ export const JOIN_MEETINGS: readonly string[];
96
96
  export const JOIN_RULES: readonly string[];
97
97
  /**
98
98
  * What a written mark says of its letter: `stress` falls on it, `length` holds
99
- * it longer, `apart` says it apart from its neighbour, and `quality` changes
100
- * which vowel is said.
99
+ * it longer, `apart` says it apart from its neighbour, `quality` changes
100
+ * which vowel is said, and `tone` marks a rising or falling pitch.
101
101
  */
102
102
  export const MARK_KINDS: readonly string[];
103
103
  /** How many of a mark a word may carry: `one` at most, or `any` number. */