@heroiclands/package-build 24.1.0 → 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.
@@ -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));
@@ -13,6 +13,9 @@
13
13
 
14
14
  /** One invocation's authored sources and context-specific prepared views. */
15
15
  import path from "node:path";
16
+ import fs from "node:fs";
17
+ import { DEPLOYED_ASSET_TYPES } from "./asset-types.mjs";
18
+ import { createContentTimings } from "./content-timings.mjs";
16
19
  import * as contentIndex from "./content-index.mjs";
17
20
  import * as blocks from "./content-blocks.mjs";
18
21
  import * as alerts from "./content-alerts.mjs";
@@ -52,6 +55,8 @@ import { lintNoteStates } from "./stub-lint.mjs";
52
55
  * @property {() => Promise<import("./content-index.mjs").PreparedContentIndex>} publication
53
56
  * @property {() => ReturnType<import("./compile-corpus.mjs").buildCompileCorpus>} compileCorpus
54
57
  * @property {(targets: readonly ContentTarget[]) => Promise<ContentSession>} prepare
58
+ * @property {ReturnType<typeof createContentTimings>} timings
59
+ * @property {() => void} assertUnchanged
55
60
  * @property {() => void} dispose
56
61
  */
57
62
 
@@ -71,6 +76,8 @@ export const CONTENT_TARGETS = Object.freeze(["index", "lint", "links", "compile
71
76
  * @param {string} [options.assetsBase] - Assets root, defaulting to configuration.
72
77
  * @param {readonly string[]} [options.skipDirectories] - Resolved content scope.
73
78
  * @param {ReturnType<typeof routerFor>} [options.router] - One router shared by this session's compile.
79
+ * @param {boolean} [options.trackChanges] - Snapshot physical input metadata for build phase guards.
80
+ * @param {ReturnType<typeof createContentTimings>} [options.timings] - Invocation stage accounting.
74
81
  * @returns {Promise<ContentSession>} Captured sources and lazy prepared views; dispose after use.
75
82
  */
76
83
  export async function createContentSession({
@@ -79,14 +86,36 @@ export async function createContentSession({
79
86
  assetsBase = config.paths.assets,
80
87
  skipDirectories = config.skipDirectories,
81
88
  router = routerFor(config),
89
+ timings = createContentTimings(),
90
+ trackChanges = false,
82
91
  } = {}) {
83
92
  const tree = path.resolve(contentBase);
84
93
  const assets = path.resolve(assetsBase);
85
94
  const scope = [...skipDirectories];
86
- const inventory = captureContentSources(tree, assets, scope);
95
+ const inventory = timings.sync("source capture", () =>
96
+ captureContentSources(tree, assets, scope),
97
+ );
98
+ const fingerprint = (file) => {
99
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
100
+ return stat ? `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` : "absent";
101
+ };
102
+ const fingerprints =
103
+ trackChanges ?
104
+ new Map(
105
+ [
106
+ ...new Set([
107
+ tree,
108
+ ...DEPLOYED_ASSET_TYPES.map((entry) => path.resolve(assets, entry.root)),
109
+ ...inventory.paths.map((entry) => entry.file),
110
+ ]),
111
+ ].map((file) => [file, fingerprint(file)]),
112
+ )
113
+ : undefined;
87
114
  const parsed = new Map();
88
115
  const problems = [];
89
- const foreign = loadForeignIndexes(config, [config.contentPackage]);
116
+ const foreign = timings.sync("dependency indexes", () =>
117
+ loadForeignIndexes(config, [config.contentPackage]),
118
+ );
90
119
  const syntaxCache = new Map();
91
120
  const prepared = new Map();
92
121
  let disposed = false;
@@ -104,23 +133,31 @@ export async function createContentSession({
104
133
  });
105
134
  return syntaxCache.get(text);
106
135
  };
107
- const records = contentIndex.indexRecordsFor({
108
- config,
109
- contentBase: tree,
110
- assetsBase: assets,
111
- skipDirectories: scope,
112
- sources: inventory.indexSources,
113
- parsed,
114
- problems,
115
- foreignIndex: foreign.index,
116
- syntax,
117
- });
136
+ const records = timings.sync("index derivation", () =>
137
+ contentIndex.indexRecordsFor({
138
+ config,
139
+ contentBase: tree,
140
+ assetsBase: assets,
141
+ skipDirectories: scope,
142
+ sources: inventory.indexSources,
143
+ parsed,
144
+ problems,
145
+ foreignIndex: foreign.index,
146
+ syntax,
147
+ }),
148
+ );
118
149
  const memo = (key, make) => {
119
150
  active();
120
- if (!prepared.has(key)) prepared.set(key, Promise.resolve().then(make));
151
+ if (!prepared.has(key))
152
+ prepared.set(
153
+ key,
154
+ Promise.resolve().then(() => timings.measure(key, make)),
155
+ );
156
+ else timings.hit(key);
121
157
  return prepared.get(key);
122
158
  };
123
159
  const session = {
160
+ timings,
124
161
  config,
125
162
  contentBase: tree,
126
163
  assetsBase: assets,
@@ -133,6 +170,17 @@ export async function createContentSession({
133
170
  problems,
134
171
  foreign,
135
172
  syntax,
173
+ /** Refuse a stale capture after any build phase changes an input. */
174
+ assertUnchanged() {
175
+ active();
176
+ if (!fingerprints)
177
+ throw new Error("Content change tracking was not enabled for this session");
178
+ for (const [file, before] of fingerprints)
179
+ if (fingerprint(file) !== before)
180
+ throw new Error(
181
+ `Content sources changed during the build: ${file}. Finish source generation before content build.`,
182
+ );
183
+ },
136
184
  /** Refuse use with a different configuration, root, scope or router. */
137
185
  assertContext(options = {}) {
138
186
  active();
@@ -0,0 +1,67 @@
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
+ /** Sequential, nested stage accounting for one content invocation. */
15
+ export function createContentTimings() {
16
+ const started = performance.now();
17
+ let changed = started;
18
+ const stack = ["orchestration"];
19
+ const stages = new Map();
20
+ const hits = new Map();
21
+ const account = () => {
22
+ const now = performance.now();
23
+ const name = stack.at(-1);
24
+ stages.set(name, (stages.get(name) ?? 0) + now - changed);
25
+ changed = now;
26
+ };
27
+ const begin = (name) => {
28
+ account();
29
+ stack.push(name);
30
+ return () => {
31
+ account();
32
+ stack.pop();
33
+ };
34
+ };
35
+ return {
36
+ /** @template T @param {string} name @param {() => T} work @returns {T} */
37
+ sync(name, work) {
38
+ const stop = begin(name);
39
+ try {
40
+ return work();
41
+ } finally {
42
+ stop();
43
+ }
44
+ },
45
+ /** @template T @param {string} name @param {() => Promise<T>|T} work @returns {Promise<T>} */
46
+ async measure(name, work) {
47
+ const stop = begin(name);
48
+ try {
49
+ return await work();
50
+ } finally {
51
+ stop();
52
+ }
53
+ },
54
+ /** @param {string} name */
55
+ hit(name) {
56
+ hits.set(name, (hits.get(name) ?? 0) + 1);
57
+ },
58
+ snapshot() {
59
+ account();
60
+ return {
61
+ totalMs: changed - started,
62
+ stages: Object.fromEntries(stages),
63
+ hits: Object.fromEntries(hits),
64
+ };
65
+ },
66
+ };
67
+ }
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
+ }