@heroiclands/package-build 24.1.1 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.0.0
4
+
5
+ ### Major Changes
6
+
7
+ **Languages and scripts**
8
+
9
+ - Add `data.roman` to every configured language note: an NFC string listing each
10
+ permitted Roman character once, with capitals, accents, digits and punctuation
11
+ explicit.
12
+ - Use `""` for no written characters; omit spaces and line breaks, and declare
13
+ inventories without ranges, automatic case expansion or inheritance. Maps and
14
+ fonts grant no extra Roman characters.
15
+ - Language-tagged source and visible link labels are checked before mapping;
16
+ configured names and their BCP 47 tags select the same inventory.
17
+ - Invalid characters and non-NFC spellings are reported at their original
18
+ positions; link destinations, attributes and verse structure are excluded.
19
+ - Only period maps to Semaron's pause; recognized native signs require explicit
20
+ native presentation and a face that draws them, while mappings never broaden
21
+ Roman inventories.
22
+ - Unassociated external tags such as `en` and `zxx` retain ordinary behavior;
23
+ standalone `package-build markdown` does not load language inventories.
24
+
3
25
  ## 24.1.1
4
26
 
5
27
  ### Patch Changes
package/docs/api.md CHANGED
@@ -1384,17 +1384,29 @@ Linting a content tree's **addresses** — the rules every package's notes are a
1384
1384
 
1385
1385
  The charset authored content is held to, so a book can choose its face. The packs and the website render in whatever font the reader's browser or Foundry supplies, and a glyph nobody has is somebody else's problem. **A book is not that.** A PDF embeds the faces it sets, so every character in the corpus is a claim on the book's typeface — and the claim is silent, which is what makes it expensive.
1386
1386
 
1387
- | Export | Signature | Returns | Use it when |
1388
- | -------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
1389
- | `isLetterTier` | `function isLetterTier(cp)` | {boolean} Whether Tier 1 admits it. | Tier 1 — the letters, and the two whitespace characters a file is made of. |
1390
- | `TYPOGRAPHY` | `const TYPOGRAPHY` | — | Tier 2 — typography, enumerated one codepoint at a time. |
1391
- | `NOTATION` | `const NOTATION` | — | Tier 3 — the notation the rules and price tables are written in. |
1392
- | `isAllowedCodePoint` | `function isAllowedCodePoint(cp)` | {boolean} Whether it is allowed outside a code fence. | Whether the charset admits a code point anywhere in a note. |
1393
- | `isDiagramCodePoint` | `function isDiagramCodePoint(cp)` | {boolean} Whether a fence may carry it. | Whether a code point is diagram furniture, admitted inside a fence only. |
1394
- | `refusalFor` | `function refusalFor(cp)` | {string} A clause naming what it is and what to do instead. | The reason a code point is refused. |
1395
- | `decomposedRuns` | `function decomposedRuns(text)` | {Array<{sequence: string, composed: string, index: number}>} Each offending run, in the order it appears. | Every non-NFC run in a string, with the composed form it should have been. |
1396
- | `checkText` | `function checkText(text, file)` | {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} What is wrong, in file order. | Check one file's text against the charset and the normalization rule. |
1397
- | `lintContentCharset` | `function lintContentCharset(contentBase,` | `{{findings: Array<{file: string, line: number, column: number, severity: "warning", message: string}>, files: number}}` The findings, and how many files produced them. | Walk a content tree and check every authored file in it. |
1387
+ | Export | Signature | Returns | Use it when |
1388
+ | -------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
1389
+ | `isLetterTier` | `function isLetterTier(cp)` | {boolean} Whether Tier 1 admits it. | Tier 1 — the letters, and the two whitespace characters a file is made of. |
1390
+ | `TYPOGRAPHY` | `const TYPOGRAPHY` | — | Tier 2 — typography, enumerated one codepoint at a time. |
1391
+ | `NOTATION` | `const NOTATION` | — | Tier 3 — the notation the rules and price tables are written in. |
1392
+ | `isSettingScriptCodePoint` | `isSettingScriptCodePoint(cp)` | `boolean` | recognizing native setting-script input before the selected face checks the individual glyph |
1393
+ | `isAllowedCodePoint` | `function isAllowedCodePoint(cp)` | {boolean} Whether it is allowed outside a code fence. | Whether the charset admits a code point anywhere in a note. |
1394
+ | `isDiagramCodePoint` | `function isDiagramCodePoint(cp)` | {boolean} Whether a fence may carry it. | Whether a code point is diagram furniture, admitted inside a fence only. |
1395
+ | `refusalFor` | `function refusalFor(cp)` | {string} A clause naming what it is and what to do instead. | The reason a code point is refused. |
1396
+ | `decomposedRuns` | `function decomposedRuns(text)` | {Array<{sequence: string, composed: string, index: number}>} Each offending run, in the order it appears. | Every non-NFC run in a string, with the composed form it should have been. |
1397
+ | `checkText` | `function checkText(text, file)` | {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} What is wrong, in file order. | Check one file's text against the charset and the normalization rule. |
1398
+ | `lintContentCharset` | `function lintContentCharset(contentBase,` | `{{findings: Array<{file: string, line: number, column: number, severity: "warning", message: string}>, files: number}}` The findings, and how many files produced them. | Walk a content tree and check every authored file in it. |
1399
+
1400
+ ### `engine.romanCharacters`
1401
+
1402
+ Exact permitted Roman source characters for configured languages, before script
1403
+ mapping. Inventories come from `data.roman` on each language's own note.
1404
+
1405
+ | Export | Signature | Returns | Use it when |
1406
+ | ------------------------ | --------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------- |
1407
+ | `romanInventoryProblems` | `romanInventoryProblems(value)` | `string[]` | checking NFC, repeated characters and forbidden whitespace or controls in a literal inventory |
1408
+ | `romanInventoriesOf` | `romanInventoriesOf(records, languages)` | `Map<string, RomanInventory>` | looking up a configured language's own inventory by name or BCP 47 tag |
1409
+ | `romanTextProblems` | `romanTextProblems(text, inventory, native?)` | `{offset, message}[]` | validating visible unescaped source with exact offsets and an optional native-sign predicate |
1398
1410
 
1399
1411
  ### `engine.pathCharset`
1400
1412
 
@@ -644,6 +644,14 @@ has an error is set exactly as written.
644
644
 
645
645
  ## Marking language, font and mapping
646
646
 
647
+ Each configured language note declares `data.roman`, a literal NFC string of
648
+ permitted source characters. Include capitals, accents, digits and punctuation
649
+ explicitly; an empty string declares no written characters. Spaces and line
650
+ breaks are structural. `package-build lint` checks Roman source before mapping,
651
+ including language-only runs and visible link labels. The configured name and
652
+ its BCP 47 tag select the same inventory; external tags without a configured
653
+ language note keep their ordinary behavior.
654
+
647
655
  `lang=` identifies the language. It preserves the authored spelling and the
648
656
  surrounding prose font. Add **`.nativefont`** to request native script:
649
657
 
package/docs/commands.md CHANGED
@@ -2215,6 +2215,14 @@ dependencies; exact Item UUID validation remains part of compilation. Reads
2215
2215
  the content tree named by `root`, defaulting to
2216
2216
  `paths.content`; writes nothing.
2217
2217
 
2218
+ Every configured language note declares `data.roman`, its exact NFC Roman
2219
+ source characters. Lint checks language-tagged text before mapping, using the
2220
+ same inventory for a configured name and its BCP 47 tag. Visible linked labels
2221
+ are checked; destinations, attributes and verse structure are excluded. An
2222
+ explicit empty inventory declares no written characters. External tags without
2223
+ a language-note association retain their ordinary behavior. Standalone
2224
+ `package-build markdown` does not load these inventories.
2225
+
2218
2226
  Name marking is advisory: lint checks explicit `.name` spans and authored link
2219
2227
  labels against the [name-marking
2220
2228
  conventions](authoring/links-and-markup.md#marking-names-in-prose). Plain prose
@@ -158,6 +158,7 @@ language note says which, as one map and one face:
158
158
 
159
159
  ```yaml
160
160
  data:
161
+ roman: "ABDEFGHIKLMNOPRSTUVWYZabdefghiklmnoprstuvwyzÁÉÍÓÚÝáéíóúýËëÀà0123456789."
161
162
  writtenIn: { map: semaron, font: semaron-kuriale }
162
163
  ```
163
164
 
@@ -300,7 +301,6 @@ data:
300
301
  - { from: "y", to: "󰉅", before: vowel } # U+F0245
301
302
  - ["y", "󰈷"] # U+F0237
302
303
  - [".", "󰉨"] # U+F0268
303
- - [",", "󰉨"] # U+F0268
304
304
  fonts: [{ font: semaron-kuriale }]
305
305
  ---
306
306
 
@@ -339,8 +339,8 @@ them, none included. A mapping that drops a language's vowels writes `[a, ""]`.
339
339
  `y` is the consonant letter before a vowel and the vowel `i` elsewhere.
340
340
  - **Capitals and marks need rows of their own.** A row matches the character as
341
341
  written, so `L` and `l` are two rows, and a script with no capitals gives both
342
- the same code point. The full stop and the comma are characters too: a run
343
- that holds one needs a row for it, or the face-coverage check reports it.
342
+ the same code point. A period is a character too and needs its own row.
343
+ A language's `data.roman` decides which source characters may appear.
344
344
  - **A character no row matches passes through unchanged,** and the face-coverage
345
345
  check then reports it. A mapping for a face that draws nothing on roman code
346
346
  points lists every letter the spelling uses.
@@ -381,14 +381,19 @@ A row with a fault is left out of the map and the rest apply.
381
381
 
382
382
  ## How a mapping treats punctuation
383
383
 
384
+ The language note's `data.roman` lists its exact permitted Roman source
385
+ characters. Include capitals, accents, digits and punctuation explicitly;
386
+ `package-build lint` checks them before mapping. A map cannot admit a character
387
+ that the language's inventory excludes. There are no automatic punctuation
388
+ substitutions.
389
+
384
390
  The toolchain is used with one convention for marks: **mood, questions and
385
391
  quotation are carried by words, not marks,** unless a language's note rules
386
392
  otherwise. A mapping therefore lists the marks its script has and nothing more.
387
- In the sample, a comma and a full stop are the script's one point:
393
+ In the sample, only a period writes the script's pause point:
388
394
 
389
395
  ```yaml
390
396
  - [".", "\U000F0268"] # U+F0268
391
- - [",", "\U000F0268"] # U+F0268
392
397
  ```
393
398
 
394
399
  (Formatting rewrites the escapes, as above.) A character with no row passes
@@ -398,10 +403,11 @@ mapped run is an error that names the mark and the mapping:
398
403
  > `` `?` (U+003F) is not drawn by the font `semaron-kuriale`, and the map `semaron` has no row for it ``
399
404
 
400
405
  The remedy is to write the question or the quotation in words, as the language
401
- does, and leave the mark out of the run. A language whose note rules
402
- otherwise gets rows for its marks in the mapping, and the check stops
403
- reporting them. The same holds for the digits, the hyphen and the catch: a row
404
- for each makes the mark part of the script; none leaves it out.
406
+ does, and leave the mark out of the run. A language whose note permits a mark
407
+ includes it in `data.roman`; its script needs a row for that mark as well.
408
+ Vylari permits period alone as punctuation. Its lexical catch is written with
409
+ a grave-accented vowel, as in `kàralzon`, and that vowel needs its own mapping
410
+ row; an apostrophe is not another spelling of it.
405
411
 
406
412
  ## The dictionary first
407
413
 
@@ -793,7 +799,7 @@ paragraphs: indentation, line breaks and spans survive formatting untouched.
793
799
 
794
800
  ````markdown
795
801
  ```verse {lang=vyl font=semaron-kuriale map=semaron .nativefont}
796
- Lokazor lonak,
802
+ Lokazor lonak.
797
803
  lokamen zaron.
798
804
  ```
799
805
  ````
@@ -1114,6 +1120,7 @@ name: { full: Vylari Language }
1114
1120
  type: skill
1115
1121
  subType: language
1116
1122
  data:
1123
+ roman: "ABDEFGHIKLMNOPRSTUVWYZabdefghiklmnoprstuvwyzÁÉÍÓÚÝáéíóúýËëÀà0123456789."
1117
1124
  writtenIn: { map: semaron, font: semaron-kuriale }
1118
1125
  core: true
1119
1126
  fields:
@@ -1177,7 +1184,7 @@ The lintel over the door of the oath road is cut with a couplet, and the
1177
1184
  stonecutter set the couplet in the script of the court, so that every traveller
1178
1185
  could read it as the court reads it.
1179
1186
 
1180
- [Lokazor lonak thiruthrimar, lokamen zaron.]{lang=vyl font=semaron-kuriale map=semaron .nativefont}
1187
+ [Lokazor lonak thiruthrimar. lokamen zaron.]{lang=vyl font=semaron-kuriale map=semaron .nativefont}
1181
1188
  ```
1182
1189
 
1183
1190
  Linting reports the unknown word, at its first character:
@@ -1198,6 +1205,7 @@ name: { full: Vylari Language }
1198
1205
  type: skill
1199
1206
  subType: language
1200
1207
  data:
1208
+ roman: "ABDEFGHIKLMNOPRSTUVWYZabdefghiklmnoprstuvwyzÁÉÍÓÚÝáéíóúýËëÀà0123456789."
1201
1209
  writtenIn: { map: semaron, font: semaron-kuriale }
1202
1210
  core: true
1203
1211
  fields:
@@ -1251,7 +1259,7 @@ The lint is now clean, and the conversion can be read. `package-build transliter
1251
1259
  prints the code points the build writes for the couplet:
1252
1260
 
1253
1261
  ```console
1254
- $ package-build transliterate semaron --lang vyl --hex "Lokazor lonak, lokamen zaron."
1262
+ $ package-build transliterate semaron --lang vyl --hex "Lokazor lonak. lokamen zaron."
1255
1263
  U+F020A U+F023D U+F0239 U+F0230 U+F0246 U+F023D U+F023F U+0020 U+F023A U+F023D U+F023C U+F0230 U+F0239 U+F0268 U+0020 U+F023A U+F023D U+F0239 U+F0230 U+F023B U+F0233 U+F023C U+0020 U+F0246 U+F0248 U+F023F U+F023D U+F023C U+F0268
1256
1264
  ```
1257
1265
 
@@ -1264,8 +1272,8 @@ The site sets the run as
1264
1272
  ```
1265
1273
 
1266
1274
  where the characters are the code points above: `Lokazor` begins with the
1267
- capital `L` of the rows, `zaron` is the exception's long vowel, and the comma
1268
- and full stop are the script's one point.
1275
+ capital `L` of the rows, `zaron` is the exception's long vowel, and each period
1276
+ writes the script's pause point.
1269
1277
 
1270
1278
  A poem in columns and a poem with its metre checked sit on one note. Both parse
1271
1279
  with no finding: the first sets native Vylari in column one and English over Roman
@@ -1341,7 +1349,7 @@ The door is marked with the same words as the lintel. Although Semaron's
1341
1349
  convention is rubric, this Vylari span uses no Markdown emphasis: spans with a
1342
1350
  non-English language cannot contain bold or italic markup.
1343
1351
 
1344
- [Lokazor lonak, zaron.]{lang=vyl font=semaron-kuriale map=semaron .nativefont}
1352
+ [Lokazor lonak. zaron.]{lang=vyl font=semaron-kuriale map=semaron .nativefont}
1345
1353
  ```
1346
1354
 
1347
1355
  Finally, the core report lists what the language still lacks:
@@ -2002,6 +2002,30 @@ in that column are grey; each row resets that choice. `numstep` counts logical
2002
2002
  rows, and `***` separates stanzas. Language and native script choices remain
2003
2003
  on the authored inline runs, independently for every reading.
2004
2004
 
2005
+ **A language note declares its Roman source inventory in `data.roman`.** It is
2006
+ one literal NFC string containing each permitted character once, including every
2007
+ capital, accent, digit and punctuation mark the language uses. For example,
2008
+ `roman: "kaKàÀ.1"` permits exactly those seven characters. There is no range
2009
+ syntax, automatic case expansion, or inventory inherited from a parent language.
2010
+ An empty string explicitly declares that the language has no written characters.
2011
+ Spaces, tabs and line breaks are structural and are not listed in the inventory.
2012
+ A missing declaration on a configured language note is an error even when no
2013
+ passage uses the language. A directly typed native sign is separate from
2014
+ Roman input only when the run explicitly selects `.nativefont`, the character
2015
+ belongs to a recognized setting-script encoding, and the selected face draws it.
2016
+ A font's ability to draw Roman punctuation does not admit that punctuation.
2017
+
2018
+ `package-build lint` checks the visible source of language-tagged spans, divs,
2019
+ verse and scroll fences against that inventory before any map or native-word
2020
+ replacement can hide a character. Both the configured name and its BCP 47 tag
2021
+ select the same inventory. A nested language selects its own; link destinations,
2022
+ attributes, column numbers, indentation periods and `***` stanza separators are
2023
+ structure. Escaping a character does not admit it. Source spelling must be NFC;
2024
+ errors retain the original file position. Unassociated external tags such as
2025
+ `en` and `zxx` keep their ordinary behavior. Standalone `package-build markdown`
2026
+ checks markup without loading language inventories. Structured frontmatter words
2027
+ are separate from prose runs.
2028
+
2005
2029
  **A face draws the code points its `fonts.json` entry lists in `codepoints`,
2006
2030
  and whitespace.** After the map, any other character in a run set in a face is
2007
2031
  an error, located at the source character it came from; through a map, at the
@@ -6095,6 +6119,7 @@ SoHL sets `system.quantity` to `1` for each compendium item.
6095
6119
  | `family` | `Address` | `language` only: the parent language, naming a skill note with `subType: language`; this language reads the parent's words as its own — see [a language's words](#a-languages-words) |
6096
6120
  | `abbreviation` | `string` | `language` only: the tag a dictionary prints for it, as `Prov.`; its full name where unstated — see [dictionaries and glossaries](#dictionaries-and-glossaries) |
6097
6121
  | `core` | `boolean` | `language` only: `true` where the language must cover the [core vocabulary](#the-core-vocabulary) |
6122
+ | `roman` | literal NFC string, may be empty | Exact permitted Roman source characters, including both cases, accents, digits and punctuation; required on each configured language note. No family inheritance or range syntax. |
6098
6123
  | `grammar` | `{ plural?, verbs?, agreement?, participles? }` | Optional noun plurals, one verb pattern, agreement and participles; supplied shapes only are checked |
6099
6124
  | `grammar.plural` | `{ ending, becomes }[]` | Literal suffix replacements in authored order; the first match wins |
6100
6125
  | `grammar.plural[].ending` | `string`, required, may be empty | Bare suffix to replace; empty matches every noun |
@@ -468,6 +468,7 @@ These fields are accepted by every note type. A field's value can still be irrel
468
468
  | `data.fields` | list of `{ name, holds }` | The fields the language's words are grouped under. A language skill note only. |
469
469
  | `data.words` | list of `{ form, class, field, gloss, … }` | The words this language states, one map each, in authored order; its automatic dictionary sorts them alphabetically. A language skill note only. See [a language's words](format-details.md#a-languages-words). |
470
470
  | `data.affixes` | list of `{ form, gloss, attaches?, examples? }` | The affixes a word's `built` pieces may name, each with its gloss. A language skill note only. |
471
+ | `data.roman` | a literal NFC string of permitted characters, including case, accents, digits and punctuation | The exact Roman source characters this language permits, each once. No ranges, case expansion or inherited inventory; spaces and line breaks are structural. An empty string declares no written characters. Required on each configured language note. |
471
472
  | `data.grammar` | a map — `{ plural?, verbs?, agreement?, participles?, native? }` | Optional noun plurals, one verb pattern, agreement, participles and the endings' logographic writings, for dictionary presentation, the mapped-run word check and logographic maps. Only the shape of supplied keys is checked; no word must follow or state a rule. A language skill note only. |
472
473
  | `data.tongues` | list of `{ tag, meaning, language? }` | The tags a register row's `tongue` may name, each with what it means. A language skill note only. |
473
474
  | `data.register` | list of `{ name, tongue?, kind?, built?, gloss?, why? }` | Every name the setting gives in the language's reach, with its tongue and pieces; the notes carrying each name are derived. A language skill note only. |
@@ -176,6 +176,17 @@ const SCRIPT_BLOCKS = Object.freeze([
176
176
  [0x13430, 0x1345f], // the Egyptian format controls Zethu uses
177
177
  ]);
178
178
 
179
+ /**
180
+ * Whether a code point belongs to a declared setting-script encoding block.
181
+ * This identifies native input independently of a language's Roman inventory;
182
+ * a selected native face must still draw the individual character.
183
+ * @param {number} cp - A Unicode code point.
184
+ * @returns {boolean}
185
+ */
186
+ export function isSettingScriptCodePoint(cp) {
187
+ return SCRIPT_BLOCKS.some(([from, to]) => cp >= from && cp <= to);
188
+ }
189
+
179
190
  /**
180
191
  * Whether the charset admits a code point anywhere in a note.
181
192
  *
@@ -184,10 +195,7 @@ const SCRIPT_BLOCKS = Object.freeze([
184
195
  */
185
196
  export function isAllowedCodePoint(cp) {
186
197
  return (
187
- isLetterTier(cp) ||
188
- TYPOGRAPHY.has(cp) ||
189
- NOTATION.has(cp) ||
190
- SCRIPT_BLOCKS.some(([from, to]) => cp >= from && cp <= to)
198
+ isLetterTier(cp) || TYPOGRAPHY.has(cp) || NOTATION.has(cp) || isSettingScriptCodePoint(cp)
191
199
  );
192
200
  }
193
201
 
@@ -200,6 +200,7 @@ types:
200
200
  - other
201
201
  skill:
202
202
  data:
203
+ - roman
203
204
  - grammar
204
205
  - templatePriority
205
206
  - masteryLevel
@@ -92,7 +92,7 @@
92
92
  import MarkdownIt from "markdown-it";
93
93
 
94
94
  import { scanBlocks, BLOCK_CONTAINERS } from "./content-blocks.mjs";
95
- import { scanVerse, hasColumns, verseLine, verseGroups } from "./content-verse.mjs";
95
+ import { scanVerse, hasColumns, verseLine, verseGroups, STANZA_BREAK } from "./content-verse.mjs";
96
96
  import { scanSpans } from "./content-spans.mjs";
97
97
  import { scanScrolls, scrollParagraphs } from "./content-scroll.mjs";
98
98
  import { directionsFromRecords } from "./script-direction.mjs";
@@ -113,7 +113,9 @@ import { unknownWordMessage, unknownWords, wordBooksFromRecords } from "./run-wo
113
113
  import { metreBooksFromRecords, metreFindings, metresFromRecords } from "./verse-metre.mjs";
114
114
  import { applyMap, mapsFromRecords } from "./text-maps.mjs";
115
115
  import { writtenInOf } from "./written-in.mjs";
116
- import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
116
+ import { romanInventoriesOf, romanTextProblems } from "./roman-characters.mjs";
117
+ import { isSettingScriptCodePoint } from "./content-charset.mjs";
118
+ import { WIKILINK } from "./wikilink-syntax.mjs";
117
119
  import { matchAllOutsideCode } from "./code-fences.mjs";
118
120
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
119
121
 
@@ -159,6 +161,8 @@ const CONFIG = "package-build.config.yaml";
159
161
  * @property {ReadonlyMap<string, import("./verse-metre.mjs").MetreBook>} [metreBooks] -
160
162
  * What each configured language's verse is counted by, from
161
163
  * {@link module:engine/verse-metre.metreBooksFromRecords}.
164
+ * @property {ReadonlyMap<string, import("./roman-characters.mjs").RomanInventory>} [romanInventories] -
165
+ * Exact Roman source inventories by configured alias and BCP 47 tag.
162
166
  * @property {ReadonlyMap<string, import("./script-emphasis.mjs").Emphasis>} [emphases] -
163
167
  * Each face's emphasis conventions, from the script notes, by
164
168
  * {@link module:engine/script-emphasis.emphasesFromRecords}.
@@ -241,6 +245,7 @@ export function runContext(config, records) {
241
245
  ...base,
242
246
  maps,
243
247
  writtenIn: writtenInOf(languageRecords, maps),
248
+ romanInventories: romanInventoriesOf(languageRecords, config.languages),
244
249
  lexicons: lexiconsFromRecords(records, config),
245
250
  wordBooks: wordBooksFromRecords(records, config),
246
251
  metres: metresFromRecords(records),
@@ -1176,12 +1181,11 @@ function analyseRuns(source, ctx, syntax) {
1176
1181
  );
1177
1182
  };
1178
1183
 
1184
+ const rawReferences = [];
1179
1185
  // Raw wikilinks belong to the resolver, not the span rewriter. Validate
1180
1186
  // their own control attributes with the same grammar before resolution,
1181
1187
  // without mapping a destination or consuming the trailing attributes.
1182
1188
  for (const match of matchAllOutsideCode(text, new RegExp(WIKILINK.source, "g"))) {
1183
- const parsed = parseWikilink(match[1], match[0]);
1184
- if (!parsed.attributes) continue;
1185
1189
  const close = match[0].indexOf("]]") + 1;
1186
1190
  const from = match.index + close + 2;
1187
1191
  const attrs = parseExtensionAttributes(match[0].slice(close + 2, -1), { positions: true });
@@ -1200,6 +1204,19 @@ function analyseRuns(source, ctx, syntax) {
1200
1204
  };
1201
1205
  const unchecked = errors.length;
1202
1206
  checkOwn(construct);
1207
+ const bar = match[1].indexOf("|");
1208
+ if (bar >= 0 && errors.length === unchecked) {
1209
+ const ancestors = around(construct);
1210
+ const inherited = ancestors.filter((parent) => parent.keys.lang !== undefined).at(-1)
1211
+ ?.keys.lang;
1212
+ rawReferences.push({
1213
+ lang: construct.keys.lang ?? inherited,
1214
+ from: match.index + 2 + bar + 1,
1215
+ to: match.index + close - 1,
1216
+ where: construct.where,
1217
+ construct,
1218
+ });
1219
+ }
1203
1220
  if (errors.length !== unchecked || !native(construct)) continue;
1204
1221
  const keys = { ...construct.keys };
1205
1222
  nativeDefaults(construct, keys);
@@ -1215,6 +1232,9 @@ function analyseRuns(source, ctx, syntax) {
1215
1232
 
1216
1233
  const verseMarkers = new Map();
1217
1234
  for (const poem of scanVerse(text).blocks) {
1235
+ for (let line = poem.start + 1; line < poem.end; line++)
1236
+ if (STANZA_BREAK.test(lines[line].trim()))
1237
+ verseMarkers.set(starts[line], starts[line] + lines[line].length);
1218
1238
  if (hasColumns(poem)) {
1219
1239
  for (const group of verseGroups(poem, lines)) {
1220
1240
  if (group.stanzaBreak) continue;
@@ -1401,6 +1421,47 @@ function analyseRuns(source, ctx, syntax) {
1401
1421
  }
1402
1422
  };
1403
1423
 
1424
+ /** The configured source inventory for either an alias or its BCP 47 tag. */
1425
+ const inventoryOf = (lang) =>
1426
+ lang === undefined ? undefined : (
1427
+ (ctx.romanInventories?.get(lang) ?? ctx.romanInventories?.get(lang.toLowerCase()))
1428
+ );
1429
+
1430
+ /** Check visible source before mapping or lexicon-native replacement. */
1431
+ const roman = (parts, detail) => {
1432
+ const inventory = inventoryOf(detail.run.lang);
1433
+ if (!inventory) return;
1434
+ if (!inventory.characters) {
1435
+ report(
1436
+ { line: detail.run.line, column: detail.run.column },
1437
+ `the language note for \`${inventory.language}\` must declare \`data.roman\``,
1438
+ );
1439
+ return;
1440
+ }
1441
+ for (const [from, to] of parts) {
1442
+ let base = from;
1443
+ for (const line of text.slice(from, to).split("\n")) {
1444
+ // A terminal unescaped backslash is a Markdown line break.
1445
+ const breakMark =
1446
+ /(?<!\\)(?:\\\\)*\\$/.test(line) && text[base + line.length] === "\n";
1447
+ const plain = unescapeRoman(breakMark ? line.slice(0, -1) : line);
1448
+ for (const problem of romanTextProblems(plain.text, inventory, (char) => {
1449
+ const point = char.codePointAt(0);
1450
+ return (
1451
+ detail.run.nativefont === true &&
1452
+ isSettingScriptCodePoint(point) &&
1453
+ detail.face?.draws.has(point)
1454
+ );
1455
+ }))
1456
+ report(
1457
+ positionOf(starts, base + (plain.offsets[problem.offset] ?? 0)),
1458
+ problem.message,
1459
+ );
1460
+ base += line.length + 1;
1461
+ }
1462
+ }
1463
+ };
1464
+
1404
1465
  /**
1405
1466
  * Report each word of a mapped run that is not a word of its language, once
1406
1467
  * per run, at its first character.
@@ -1515,6 +1576,7 @@ function analyseRuns(source, ctx, syntax) {
1515
1576
  };
1516
1577
  runs.push(run);
1517
1578
  const plain =
1579
+ inventoryOf(lang) !== undefined ||
1518
1580
  font !== undefined ||
1519
1581
  map !== undefined ||
1520
1582
  (lang !== undefined &&
@@ -1577,15 +1639,18 @@ function analyseRuns(source, ctx, syntax) {
1577
1639
  }
1578
1640
  if (plain && errors.length === before)
1579
1641
  plainInline(inline, from, marking, detail, detail.holes);
1580
- if (named && errors.length === before) {
1642
+ if (errors.length === before) {
1581
1643
  const parts = outsideDelimiters(
1582
1644
  outsideHoles(from, to, detail.holes),
1583
1645
  detail.emphasis,
1584
1646
  );
1585
- lexical(parts, detail);
1586
- capitals(parts, detail);
1587
- writings(parts, detail);
1588
- covered(parts, detail);
1647
+ roman(parts, detail);
1648
+ if (named) {
1649
+ lexical(parts, detail);
1650
+ capitals(parts, detail);
1651
+ writings(parts, detail);
1652
+ covered(parts, detail);
1653
+ }
1589
1654
  }
1590
1655
  continue;
1591
1656
  }
@@ -1647,15 +1712,30 @@ function analyseRuns(source, ctx, syntax) {
1647
1712
  for (let line = first; line < last; line++)
1648
1713
  plainInline(lines[line], starts[line], marking, detail, detail.holes);
1649
1714
  }
1650
- if (named && errors.length === before) {
1715
+ if (errors.length === before) {
1651
1716
  const parts = outsideDelimiters(outsideHoles(from, to, detail.holes), detail.emphasis);
1652
- lexical(parts, detail);
1653
- capitals(parts, detail);
1654
- writings(parts, detail);
1655
- covered(parts, detail);
1717
+ roman(parts, detail);
1718
+ if (named) {
1719
+ lexical(parts, detail);
1720
+ capitals(parts, detail);
1721
+ writings(parts, detail);
1722
+ covered(parts, detail);
1723
+ }
1656
1724
  }
1657
1725
  }
1658
1726
 
1727
+ for (const reference of rawReferences) {
1728
+ const font = reference.construct.keys.font ?? ctx.writtenIn?.get(reference.lang)?.font;
1729
+ roman([[reference.from, reference.to]], {
1730
+ run: {
1731
+ lang: reference.lang,
1732
+ nativefont: native(reference.construct),
1733
+ ...reference.where,
1734
+ },
1735
+ face: font === undefined ? undefined : faces.get(font),
1736
+ });
1737
+ }
1738
+
1659
1739
  const order = (a, b) => a.line - b.line || a.column - b.column;
1660
1740
  runs.sort(order);
1661
1741
  details.sort((a, b) => order(a.run, b.run));
package/engine/index.mjs CHANGED
@@ -227,6 +227,9 @@ export * as contentLint from "./content-lint.mjs";
227
227
 
228
228
  /** The charset authored content is held to, so a book can choose its face. */
229
229
  export * as contentCharset from "./content-charset.mjs";
230
+
231
+ /** Exact permitted Roman source characters for configured languages. */
232
+ export * as romanCharacters from "./roman-characters.mjs";
230
233
  export * as pathCharset from "./path-charset.mjs";
231
234
 
232
235
  /** Naming an interface icon in a note, without drawing one there. */
@@ -73,6 +73,7 @@ import {
73
73
  import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
74
74
  import { WORD_CLASS_BASES } from "./word-classes.mjs";
75
75
  import { nearest } from "./near-miss.mjs";
76
+ import { romanInventoryProblems } from "./roman-characters.mjs";
76
77
 
77
78
  /** The bases a frame's `class` names. */
78
79
  const CLASS_BASES = new Set(WORD_CLASS_BASES.map((item) => item.token));
@@ -614,7 +615,9 @@ export function checkLanguageData(note, { index, types } = {}) {
614
615
  ),
615
616
  );
616
617
 
617
- const findings = [];
618
+ const findings = romanInventoryProblems(data.roman).map((message) =>
619
+ finding(["data", "roman"], "error", message),
620
+ );
618
621
 
619
622
  // Only the shape of a supplied declaration: no word is compared with it.
620
623
  const grammar = isMap(data.grammar) ? data.grammar : {};
@@ -30,6 +30,7 @@
30
30
 
31
31
  /** The `data` keys a language note states its sounds, names and history in. */
32
32
  export const LANGUAGE_DATA_KEYS = Object.freeze([
33
+ "roman",
33
34
  "tongues",
34
35
  "register",
35
36
  "retired",
@@ -1889,6 +1890,15 @@ const GRAMMAR_FIELDS = Object.freeze([
1889
1890
  * @type {readonly object[]}
1890
1891
  */
1891
1892
  export const LANGUAGE_DATA_FIELDS = Object.freeze([
1893
+ Object.freeze({
1894
+ name: "roman",
1895
+ kind: "string",
1896
+ shape: "a literal NFC string of permitted characters, including case, accents, digits and punctuation",
1897
+ describe:
1898
+ "The exact Roman source characters this language permits, each once. No ranges, case " +
1899
+ "expansion or inherited inventory; spaces and line breaks are structural. An empty " +
1900
+ "string declares no written characters. Required on each configured language note.",
1901
+ }),
1892
1902
  Object.freeze({
1893
1903
  name: "grammar",
1894
1904
  kind: "map",
@@ -330,7 +330,18 @@ export function languageNoteFindings(config, records) {
330
330
  }
331
331
  const findings = [];
332
332
  for (const [name, { record }] of languageNotes(languages, contentPackage, records)) {
333
- if (record) continue;
333
+ if (record) {
334
+ if (typeof record.data?.roman !== "string")
335
+ findings.push({
336
+ file: configFile ?? CONFIG,
337
+ ...(text ?
338
+ positionOfYamlPath(text, ["languages", name, "note"], { key: true })
339
+ : {}),
340
+ severity: "error",
341
+ message: `the language note named by \`languages.${name}.note\` must declare \`data.roman\``,
342
+ });
343
+ continue;
344
+ }
334
345
  findings.push({
335
346
  file: configFile ?? CONFIG,
336
347
  ...(text ? positionOfYamlPath(text, ["languages", name, "note"], { key: true }) : {}),
@@ -0,0 +1,113 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /** A language's exact Roman source characters, before any script conversion. */
15
+
16
+ /** Unicode graphemes keep every canonically composing sequence together. */
17
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
18
+
19
+ /** A code point as the author sees it in a finding. */
20
+ const codeName = (char) => `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
21
+
22
+ /**
23
+ * Semantic problems in a supplied inventory. Shape belongs to the vocabulary.
24
+ * An empty string explicitly declares no written characters.
25
+ * @param {unknown} value - The authored `data.roman`.
26
+ * @returns {string[]} Findings at that field.
27
+ */
28
+ export function romanInventoryProblems(value) {
29
+ if (typeof value !== "string") return [];
30
+ const problems = [];
31
+ if (value !== value.normalize("NFC"))
32
+ problems.push(
33
+ "`data.roman` must use NFC spelling; write precomposed accented letters where available",
34
+ );
35
+ if (/\s/u.test(value))
36
+ problems.push(
37
+ "`data.roman` lists characters without whitespace; spaces and line breaks are structural",
38
+ );
39
+ if (/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value))
40
+ problems.push("`data.roman` contains a control, format or unpaired surrogate character");
41
+ const seen = new Set();
42
+ const repeated = new Set();
43
+ for (const char of value) {
44
+ if (seen.has(char)) repeated.add(char);
45
+ seen.add(char);
46
+ }
47
+ if (repeated.size)
48
+ problems.push(
49
+ `\`data.roman\` repeats ${[...repeated].map((char) => `\`${char}\` (${codeName(char)})`).join(", ")}; list each character once`,
50
+ );
51
+ return problems;
52
+ }
53
+
54
+ /**
55
+ * @typedef {object} RomanInventory
56
+ * @property {string} language - The configured language name.
57
+ * @property {ReadonlySet<string>|undefined} characters - Exact code points, absent for a missing declaration.
58
+ */
59
+
60
+ /**
61
+ * Each configured language note's own inventory, by alias and BCP 47 tag.
62
+ * Language-family ancestry does not supply an inventory. Unassociated external
63
+ * tags have no entry and keep their ordinary behavior.
64
+ * @param {ReadonlyMap<string, Record<string, any>>} records - Configured language notes.
65
+ * @param {Readonly<Record<string, {tag: string}>>} languages - Language configuration.
66
+ * @returns {Map<string, RomanInventory>}
67
+ */
68
+ export function romanInventoriesOf(records, languages = {}) {
69
+ const out = new Map();
70
+ for (const [language, record] of records) {
71
+ const value = record.data?.roman;
72
+ const inventory = Object.freeze({
73
+ language,
74
+ characters: typeof value === "string" ? new Set(value) : undefined,
75
+ });
76
+ out.set(language, inventory);
77
+ const tag = languages[language]?.tag;
78
+ if (typeof tag === "string") out.set(tag.toLowerCase(), inventory);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * Invalid source characters, retaining offsets in the supplied unescaped text.
85
+ * No punctuation, case or normalization repair is performed.
86
+ * @param {string} text - Visible Roman source.
87
+ * @param {RomanInventory} inventory - Its language's declared characters.
88
+ * @param {(char: string) => boolean} [native] - Whether a directly typed native sign is drawn in the selected native face.
89
+ * @returns {Array<{offset: number, message: string}>}
90
+ */
91
+ export function romanTextProblems(text, inventory, native = () => false) {
92
+ const out = [];
93
+ if (!inventory.characters) return out;
94
+ for (const { segment: cluster, index } of graphemes.segment(text)) {
95
+ let offset = index;
96
+ if (cluster !== cluster.normalize("NFC")) {
97
+ out.push({
98
+ offset,
99
+ message: `\`${cluster}\` is not NFC spelling in \`${inventory.language}\`; write \`${cluster.normalize("NFC")}\``,
100
+ });
101
+ continue;
102
+ }
103
+ for (const char of cluster) {
104
+ if (!/[ \t\r\n]/u.test(char) && !inventory.characters.has(char) && !native(char))
105
+ out.push({
106
+ offset,
107
+ message: `\`${char}\` (${codeName(char)}) is not in \`${inventory.language}\`'s \`data.roman\` inventory`,
108
+ });
109
+ offset += char.length;
110
+ }
111
+ }
112
+ return out;
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "24.1.1",
3
+ "version": "25.0.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -9,6 +9,14 @@
9
9
  * @returns {boolean} Whether Tier 1 admits it.
10
10
  */
11
11
  export function isLetterTier(cp: number): boolean;
12
+ /**
13
+ * Whether a code point belongs to a declared setting-script encoding block.
14
+ * This identifies native input independently of a language's Roman inventory;
15
+ * a selected native face must still draw the individual character.
16
+ * @param {number} cp - A Unicode code point.
17
+ * @returns {boolean}
18
+ */
19
+ export function isSettingScriptCodePoint(cp: number): boolean;
12
20
  /**
13
21
  * Whether the charset admits a code point anywhere in a note.
14
22
  *
@@ -254,6 +254,11 @@ export type RunContext = {
254
254
  * {@link module :engine/verse-metre.metreBooksFromRecords}.
255
255
  */
256
256
  metreBooks?: ReadonlyMap<string, import("./verse-metre.mjs").MetreBook> | undefined;
257
+ /**
258
+ * -
259
+ * Exact Roman source inventories by configured alias and BCP 47 tag.
260
+ */
261
+ romanInventories?: ReadonlyMap<string, import("./roman-characters.mjs").RomanInventory> | undefined;
257
262
  /**
258
263
  * -
259
264
  * Each face's emphasis conventions, from the script notes, by
@@ -65,6 +65,7 @@ export * as previewIndex from "./preview-index.mjs";
65
65
  export * as contentRender from "./content-render.mjs";
66
66
  export * as contentLint from "./content-lint.mjs";
67
67
  export * as contentCharset from "./content-charset.mjs";
68
+ export * as romanCharacters from "./roman-characters.mjs";
68
69
  export * as pathCharset from "./path-charset.mjs";
69
70
  export * as contentIcons from "./content-icons.mjs";
70
71
  export * as contentHtml from "./content-html.mjs";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Semantic problems in a supplied inventory. Shape belongs to the vocabulary.
3
+ * An empty string explicitly declares no written characters.
4
+ * @param {unknown} value - The authored `data.roman`.
5
+ * @returns {string[]} Findings at that field.
6
+ */
7
+ export function romanInventoryProblems(value: unknown): string[];
8
+ /**
9
+ * @typedef {object} RomanInventory
10
+ * @property {string} language - The configured language name.
11
+ * @property {ReadonlySet<string>|undefined} characters - Exact code points, absent for a missing declaration.
12
+ */
13
+ /**
14
+ * Each configured language note's own inventory, by alias and BCP 47 tag.
15
+ * Language-family ancestry does not supply an inventory. Unassociated external
16
+ * tags have no entry and keep their ordinary behavior.
17
+ * @param {ReadonlyMap<string, Record<string, any>>} records - Configured language notes.
18
+ * @param {Readonly<Record<string, {tag: string}>>} languages - Language configuration.
19
+ * @returns {Map<string, RomanInventory>}
20
+ */
21
+ export function romanInventoriesOf(records: ReadonlyMap<string, Record<string, any>>, languages?: Readonly<Record<string, {
22
+ tag: string;
23
+ }>>): Map<string, RomanInventory>;
24
+ /**
25
+ * Invalid source characters, retaining offsets in the supplied unescaped text.
26
+ * No punctuation, case or normalization repair is performed.
27
+ * @param {string} text - Visible Roman source.
28
+ * @param {RomanInventory} inventory - Its language's declared characters.
29
+ * @param {(char: string) => boolean} [native] - Whether a directly typed native sign is drawn in the selected native face.
30
+ * @returns {Array<{offset: number, message: string}>}
31
+ */
32
+ export function romanTextProblems(text: string, inventory: RomanInventory, native?: (char: string) => boolean): Array<{
33
+ offset: number;
34
+ message: string;
35
+ }>;
36
+ export type RomanInventory = {
37
+ /**
38
+ * - The configured language name.
39
+ */
40
+ language: string;
41
+ /**
42
+ * - Exact code points, absent for a missing declaration.
43
+ */
44
+ characters: ReadonlySet<string> | undefined;
45
+ };