@heroiclands/package-build 25.0.0 → 25.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -0
  3. package/bin/content-commands.mjs +30 -96
  4. package/bin/encoding-commands.mjs +146 -0
  5. package/bin/package-build.mjs +35 -24
  6. package/config.mjs +25 -0
  7. package/convert.mjs +182 -0
  8. package/docs/api.md +144 -6
  9. package/docs/authoring/links-and-markup.md +260 -481
  10. package/docs/commands.md +110 -119
  11. package/docs/configuration.md +31 -13
  12. package/docs/content-format.md +11 -4
  13. package/docs/diagnostics.md +43 -16
  14. package/docs/encoding-core.md +193 -0
  15. package/docs/guides/book.md +2 -2
  16. package/docs/guides/module-project.md +23 -1
  17. package/docs/guides/site.md +2 -2
  18. package/docs/guides/writing-in-invented-scripts.md +458 -575
  19. package/docs/reference/format-details.md +292 -404
  20. package/docs/reference/note-types.md +83 -63
  21. package/docs/source-preview.md +8 -0
  22. package/e2e.mjs +1 -1
  23. package/engine/base-compiler.mjs +27 -9
  24. package/engine/content-charset.mjs +13 -1
  25. package/engine/content-format.yaml +6 -2
  26. package/engine/content-index.mjs +6 -2
  27. package/engine/content-links.mjs +1 -0
  28. package/engine/content-scripts.mjs +614 -459
  29. package/engine/content-scroll.mjs +4 -4
  30. package/engine/content-spans.mjs +40 -9
  31. package/engine/content-verse.mjs +1 -1
  32. package/engine/converter-data.mjs +70 -0
  33. package/engine/dictionary-rows.mjs +2 -34
  34. package/engine/encoding-integrity.mjs +94 -0
  35. package/engine/encoding-language.mjs +506 -0
  36. package/engine/encoding-lexicon.mjs +701 -0
  37. package/engine/encoding-notes.mjs +186 -0
  38. package/engine/encoding-standard.mjs +42 -0
  39. package/engine/encoding-template.mjs +370 -0
  40. package/engine/encoding-terms.mjs +178 -0
  41. package/engine/encoding.mjs +952 -0
  42. package/engine/extension-attributes.mjs +2 -0
  43. package/engine/helpers.mjs +1 -1
  44. package/engine/index.mjs +7 -0
  45. package/engine/language-terms.mjs +17 -16
  46. package/engine/{logographic.mjs → language-writings.mjs} +29 -38
  47. package/engine/markup-validation.mjs +1 -1
  48. package/engine/note-vocabulary.mjs +4 -158
  49. package/engine/pdf-render.mjs +31 -3
  50. package/engine/roman-characters.mjs +135 -16
  51. package/engine/roman-run.mjs +149 -0
  52. package/engine/roman-verse.mjs +95 -0
  53. package/engine/run-rows.mjs +29 -31
  54. package/engine/run-words.mjs +2 -2
  55. package/engine/script-emphasis-terms.mjs +3 -0
  56. package/engine/script-emphasis.mjs +3 -10
  57. package/engine/script-font-delivery.mjs +9 -12
  58. package/engine/script-fonts.mjs +2 -1
  59. package/engine/site-build.mjs +14 -5
  60. package/engine/site-preview.mjs +12 -2
  61. package/engine/sql-tables.mjs +36 -31
  62. package/engine/verse-metre.mjs +1 -1
  63. package/engine/word-forms.mjs +1 -1
  64. package/engine/word-inflections.mjs +48 -0
  65. package/engine/word-rows.mjs +1 -1
  66. package/engine/word-terms.mjs +16 -17
  67. package/package.json +11 -5
  68. package/types/config.d.mts +5 -0
  69. package/types/convert.d.mts +44 -0
  70. package/types/e2e.d.mts +1 -1
  71. package/types/engine/base-compiler.d.mts +4 -2
  72. package/types/engine/content-index.d.mts +4 -1
  73. package/types/engine/content-scripts.d.mts +103 -42
  74. package/types/engine/converter-data.d.mts +11 -0
  75. package/types/engine/dictionary-rows.d.mts +1 -25
  76. package/types/engine/encoding-integrity.d.mts +14 -0
  77. package/types/engine/encoding-language.d.mts +36 -0
  78. package/types/engine/encoding-lexicon.d.mts +259 -0
  79. package/types/engine/encoding-notes.d.mts +39 -0
  80. package/types/engine/encoding-standard.d.mts +11 -0
  81. package/types/engine/encoding-template.d.mts +44 -0
  82. package/types/engine/encoding-terms.d.mts +143 -0
  83. package/types/engine/encoding.d.mts +155 -0
  84. package/types/engine/helpers.d.mts +1 -1
  85. package/types/engine/index.d.mts +4 -0
  86. package/types/engine/language-terms.d.mts +1 -1
  87. package/types/engine/{logographic.d.mts → language-writings.d.mts} +6 -6
  88. package/types/engine/markup-validation.d.mts +1 -1
  89. package/types/engine/pdf-render.d.mts +1 -1
  90. package/types/engine/roman-characters.d.mts +2 -2
  91. package/types/engine/roman-run.d.mts +101 -0
  92. package/types/engine/roman-verse.d.mts +23 -0
  93. package/types/engine/run-rows.d.mts +13 -20
  94. package/types/engine/script-emphasis-terms.d.mts +2 -0
  95. package/types/engine/script-emphasis.d.mts +1 -1
  96. package/types/engine/script-font-delivery.d.mts +2 -3
  97. package/types/engine/script-fonts.d.mts +5 -0
  98. package/types/engine/site-preview.d.mts +1 -1
  99. package/types/engine/word-inflections.d.mts +25 -0
  100. package/engine/map-lexicon.mjs +0 -1045
  101. package/engine/text-maps.mjs +0 -654
  102. package/engine/written-in.mjs +0 -204
  103. package/types/engine/map-lexicon.d.mts +0 -330
  104. package/types/engine/text-maps.d.mts +0 -189
  105. package/types/engine/written-in.d.mts +0 -76
@@ -0,0 +1,186 @@
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
+ /** Encoding documents and glyph codes read from the consumer's content and fonts. */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { compileEncoding, EncodingError } from "./encoding.mjs";
18
+ import { parseAddress, renderAddress, isAddressTuple } from "./address.mjs";
19
+ import { positionOfFrontmatterPath, positionOfYamlPath } from "./diagnostics.mjs";
20
+ import { isNoteRecord } from "./index-records.mjs";
21
+ import { addressSuffixes } from "./address-render.mjs";
22
+ import { faceSlug } from "./script-fonts.mjs";
23
+
24
+ function codePointPosition(raw, position) {
25
+ if (position.line === undefined || position.column === undefined) return position;
26
+ const line = raw.split("\n")[position.line - 1] ?? "";
27
+ return { ...position, column: [...line.slice(0, position.column - 1)].length + 1 };
28
+ }
29
+
30
+ /** Complete an encoding reference with the document type and citing package.
31
+ * @param {unknown} value - Authored encoding Address or default.
32
+ * @param {string} [contentPackage] - The citing note's package.
33
+ * @returns {string|undefined} Canonical Address, default, or an invalid reference.
34
+ */
35
+ export function encodingAddress(value, contentPackage) {
36
+ if (value === "default" || value === undefined) return "default";
37
+ const parsed = parseAddress(
38
+ value,
39
+ {
40
+ package: contentPackage,
41
+ system: "note",
42
+ type: "doc",
43
+ types: new Set(["doc"]),
44
+ },
45
+ { declared: true },
46
+ );
47
+ return isAddressTuple(parsed) && parsed.type === "doc" ? renderAddress(parsed) : undefined;
48
+ }
49
+
50
+ /** The executable fields a document owns, excluding ordinary document metadata.
51
+ * @param {Record<string, any>} data - Authored document data.
52
+ * @returns {Record<string, any>} Only the compiler's fields.
53
+ */
54
+ export function encodingData(data) {
55
+ return Object.fromEntries(
56
+ ["language", "font", "accepts", "vowels", "mappings", "rules"]
57
+ .filter((key) => data?.[key] !== undefined)
58
+ .map((key) => [key, data[key]]),
59
+ );
60
+ }
61
+
62
+ /** Read this selected face's published glyph codes, with no external code tables.
63
+ * @param {import("./script-fonts.mjs").ScriptFonts|undefined} fonts - Consumer fonts.
64
+ * @param {string} font - The selected face's slug.
65
+ * @returns {Record<string,string>} Code-to-Unicode mapping for this font only.
66
+ */
67
+ export function fontCodes(fonts, font) {
68
+ const file = fonts?.root ? path.join(fonts.root, "fonts.json") : "package.json";
69
+ const familyIndex = fonts?.manifest?.families?.findIndex((entry) => faceSlug(entry) === font);
70
+ const raw = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
71
+ const failure = (message, keyPath = []) => {
72
+ throw Object.assign(new EncodingError(message), {
73
+ file,
74
+ ...(raw && keyPath.length ?
75
+ codePointPosition(
76
+ raw,
77
+ positionOfYamlPath(raw, ["families", familyIndex, ...keyPath]),
78
+ )
79
+ : {}),
80
+ });
81
+ };
82
+ const face = fonts?.manifest?.families?.find((entry) => faceSlug(entry) === font);
83
+ if (!face) failure(`Font \`${font}\` is not installed from @heroiclands/fonts`);
84
+ const codes = Object.create(null);
85
+ for (const [pointIndex, point] of (face.codepoints ?? []).entries()) {
86
+ if (typeof point.code !== "string" || !/^[A-Za-z0-9_]+$/.test(point.code))
87
+ failure(`Font \`${font}\` has a missing or invalid glyph code at ${point.codepoint}`, [
88
+ "codepoints",
89
+ pointIndex,
90
+ "code",
91
+ ]);
92
+ const matched = /^U\+([0-9A-F]{4,6})$/.exec(point.codepoint ?? "");
93
+ const scalar = matched ? Number.parseInt(matched[1], 16) : -1;
94
+ if (scalar < 0 || scalar > 0x10ffff || (scalar >= 0xd800 && scalar <= 0xdfff))
95
+ failure(`Font \`${font}\` has an invalid code point ${point.codepoint}`, [
96
+ "codepoints",
97
+ pointIndex,
98
+ "codepoint",
99
+ ]);
100
+ if (Object.hasOwn(codes, point.code))
101
+ failure(`Font \`${font}\` repeats glyph code \`${point.code}\``, [
102
+ "codepoints",
103
+ pointIndex,
104
+ "code",
105
+ ]);
106
+ codes[point.code] = String.fromCodePoint(scalar);
107
+ }
108
+ if (!Object.keys(codes).length) failure(`Font \`${font}\` publishes no glyph codes`);
109
+ return codes;
110
+ }
111
+
112
+ /** Compile each encoding document once and resolve references by document Address.
113
+ * @param {Iterable<Record<string, any>>} records - Content index records.
114
+ * @param {{languages?: Record<string, any>, contentPackage?: string, rootDir?: string, paths?: {content?: string}}} [config] - Consumer context.
115
+ * @param {import("./script-fonts.mjs").ScriptFonts} [fonts] - Installed glyph metadata.
116
+ * @returns {{encodings: Map<string, ReturnType<typeof compileEncoding>>, definitions: Map<string,{data:Record<string,any>,codes:Record<string,string>}>, findings:object[]}} Runtime and serializable document inputs.
117
+ */
118
+ export function encodingsFromRecords(records, config = {}, fonts) {
119
+ const encodings = new Map(),
120
+ definitions = new Map(),
121
+ findings = [];
122
+ for (const record of records ?? []) {
123
+ if (
124
+ !isNoteRecord(record) ||
125
+ record.type !== "doc" ||
126
+ (!Object.hasOwn(record.data ?? {}, "mappings") &&
127
+ !Object.hasOwn(record.data ?? {}, "rules"))
128
+ )
129
+ continue;
130
+ const identity = {
131
+ package: record.package ?? config.contentPackage,
132
+ system: "note",
133
+ type: "doc",
134
+ shortcode: record.shortcode,
135
+ };
136
+ const name = renderAddress(identity);
137
+ const file =
138
+ typeof record.file === "string" ?
139
+ record.file
140
+ : path.resolve(
141
+ config.paths?.content ?? config.rootDir ?? ".",
142
+ record.file?.path ?? "",
143
+ );
144
+ let raw = record.raw ?? "";
145
+ if (!raw && fs.existsSync(file) && fs.statSync(file).isFile())
146
+ raw = fs.readFileSync(file, "utf8");
147
+ try {
148
+ const data = encodingData(record.data);
149
+ if (!Object.hasOwn(config.languages ?? {}, data.language))
150
+ throw new EncodingError(
151
+ `Encoding language \`${data.language}\` is not configured`,
152
+ { key: "language" },
153
+ );
154
+ const codes = fontCodes(fonts, data.font);
155
+ const encoding = compileEncoding(data, { codes, name });
156
+ if (encodings.has(name))
157
+ throw new EncodingError(`Encoding document \`${name}\` is repeated`);
158
+ encodings.set(name, encoding);
159
+ definitions.set(name, { data, codes });
160
+ if ((record.package ?? config.contentPackage) === config.contentPackage) {
161
+ for (const alias of addressSuffixes(identity))
162
+ if (alias !== "default") encodings.set(alias, encoding);
163
+ }
164
+ } catch (error) {
165
+ const own = !error.file || error.file === file;
166
+ const key =
167
+ error.key?.startsWith("mappings.") ?
168
+ ["mappings", error.key.slice("mappings.".length)]
169
+ : (error.key
170
+ ?.split(".")
171
+ .map((part) => (/^\d+$/.test(part) ? Number(part) : part)) ?? []);
172
+ findings.push({
173
+ file: error.file ?? file,
174
+ ...(own ?
175
+ codePointPosition(raw, positionOfFrontmatterPath(raw, ["data", ...key]))
176
+ : {
177
+ ...(error.line === undefined ? {} : { line: error.line }),
178
+ ...(error.column === undefined ? {} : { column: error.column }),
179
+ }),
180
+ severity: "error",
181
+ message: error.message,
182
+ });
183
+ }
184
+ }
185
+ return { encodings, definitions, findings };
186
+ }
@@ -0,0 +1,42 @@
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
+ /** Shared Roman punctuation and invisible structural controls for every encoding. */
15
+ export const STANDARD_ROWS = Object.freeze(
16
+ [
17
+ [".", "\u{E0100}"],
18
+ ["?", "\u{E0101}"],
19
+ ["‼", "\u{E0102}"],
20
+ ["!", "\u{E0103}"],
21
+ { from: "^", to: "\u{E0104}", at: "line-start" },
22
+ [",", "\u{E0105}"],
23
+ ["«", "\u{E0106}"],
24
+ ["»", "\u{E0107}"],
25
+ ["‹", "\u{E0108}"],
26
+ ["›", "\u{E0109}"],
27
+ ["—", "\u{E010A}"],
28
+ ["…", "\u{E010B}"],
29
+ ["/", "\u{E010C}"],
30
+ ["{", "\u{E0110}"],
31
+ ["}", "\u{E0111}"],
32
+ ].map(Object.freeze),
33
+ );
34
+ /** Controls belong to the engine and never come from font encoding files. */
35
+ export const STANDARD_CONTROLS = Object.freeze({
36
+ verseStanza: "\u{E010D}",
37
+ verseCanto: "\u{E010E}",
38
+ wordSeparator: " ",
39
+ literalOpen: "\u{E0112}",
40
+ literalClose: "\u{E0113}",
41
+ literalEscape: "\u{E0115}",
42
+ });
@@ -0,0 +1,370 @@
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
+ /** Compile the closed encoding-note template into the engine's internal row program. */
15
+ import { STANDARD_ROWS, STANDARD_CONTROLS } from "./encoding-standard.mjs";
16
+ import { assertUniqueOutputs } from "./encoding-integrity.mjs";
17
+
18
+ const map = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
19
+ const scalar = (value) =>
20
+ typeof value === "string" &&
21
+ !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
22
+ const base = (value) => value.normalize("NFD").replace(/\p{M}/gu, "");
23
+ const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
24
+ const codeName = /^[A-Za-z0-9_]+$/;
25
+
26
+ /**
27
+ * Validate note data and resolve its code references without reading files.
28
+ * @param {any} data - Encoding-note data, excluding ordinary document metadata.
29
+ * @param {{codes?: Record<string,string>, name?: string}} options - Selected font codes and document identity.
30
+ * @param {(key:string,message:string)=>never} fault - Schema-error constructor supplied by the engine.
31
+ */
32
+ export function templateProgram(data, options, fault) {
33
+ const closed = (value, allowed, key) => {
34
+ if (!map(value)) fault(key, `${key || "Encoding data"} must be an object`);
35
+ for (const name of Object.keys(value))
36
+ if (!allowed.includes(name))
37
+ fault(key ? `${key}.${name}` : name, `Unknown encoding key ${name}`);
38
+ };
39
+ const exact = (value, expected, key) => {
40
+ if (value !== expected) fault(key, `${key} must be ${JSON.stringify(expected)}`);
41
+ };
42
+ const fixed = (value, fields, key) => {
43
+ closed(value, Object.keys(fields), key);
44
+ for (const [name, expected] of Object.entries(fields))
45
+ exact(value[name], expected, `${key}.${name}`);
46
+ };
47
+ closed(data, ["language", "font", "accepts", "vowels", "mappings", "rules"], "");
48
+ closed(options, ["codes", "name"], "options");
49
+ for (const key of ["language", "font"])
50
+ if (typeof data[key] !== "string" || !slug.test(data[key]))
51
+ fault(key, `${key} must be a lowercase slug`);
52
+ if (options.name !== undefined && (!scalar(options.name) || !options.name))
53
+ fault("options.name", "name must be a nonempty document identity");
54
+ if (!map(options.codes)) fault("options.codes", "Supply the selected font's glyph code table");
55
+ for (const [name, target] of Object.entries(options.codes)) {
56
+ if (!codeName.test(name))
57
+ fault(`options.codes.${name}`, "A glyph code must be a short named code");
58
+ if (!scalar(target) || !target)
59
+ fault(
60
+ `options.codes.${name}`,
61
+ "A glyph code must resolve to nonempty Unicode scalar text",
62
+ );
63
+ }
64
+ const resolve = (value, key, allowEmpty = false) => {
65
+ const sequence = typeof value === "string" ? [value] : value;
66
+ if (!Array.isArray(sequence) || (!sequence.length && !allowEmpty))
67
+ fault(
68
+ key,
69
+ "A mapping is a glyph code or a code sequence; an empty sequence requires canonical round trips",
70
+ );
71
+ return sequence
72
+ .map((name) => {
73
+ if (
74
+ typeof name !== "string" ||
75
+ !codeName.test(name) ||
76
+ !Object.hasOwn(options.codes, name)
77
+ )
78
+ fault(key, `Unknown glyph code ${String(name)}`);
79
+ return options.codes[name];
80
+ })
81
+ .join("");
82
+ };
83
+ closed(
84
+ data.rules,
85
+ [
86
+ "normalization",
87
+ "matching",
88
+ "roundTrip",
89
+ "word-separators",
90
+ "case",
91
+ "stress",
92
+ "vowel-groups",
93
+ "diaeresis",
94
+ "grave-accent",
95
+ "contextual-mappings",
96
+ "y",
97
+ ],
98
+ "rules",
99
+ );
100
+ const rules = data.rules;
101
+ exact(rules.normalization, "NFC", "rules.normalization");
102
+ exact(rules.matching, "longest-first", "rules.matching");
103
+ if (rules.roundTrip !== undefined && !["exact", "canonical"].includes(rules.roundTrip))
104
+ fault("rules.roundTrip", "roundTrip is exact or canonical");
105
+ const canonical = rules.roundTrip === "canonical";
106
+ if (rules.case !== undefined)
107
+ fixed(
108
+ rules.case,
109
+ {
110
+ "single-letter": "preserve",
111
+ "consonant-group": "lowercase-or-initial-uppercase",
112
+ "other-group-cases": "forbidden",
113
+ },
114
+ "rules.case",
115
+ );
116
+ for (const key of ["diaeresis", "grave-accent"])
117
+ if (rules[key] !== undefined) {
118
+ closed(rules[key], ["allowed"], `rules.${key}`);
119
+ if (typeof rules[key].allowed !== "boolean")
120
+ fault(`rules.${key}.allowed`, "allowed must be true or false");
121
+ }
122
+ const vowels = data.vowels ?? "";
123
+ if (!scalar(vowels)) fault("vowels", "vowels must be a Unicode scalar string");
124
+ const vowelSet = new Set([...vowels].map(base));
125
+ if (rules.y !== undefined) {
126
+ fixed(rules.y, { kind: "consonant", sound: "y in yes", "vowel-reading": false }, "rules.y");
127
+ if (/[yY]/u.test(vowels)) fault("rules.y", "Consonant y cannot be declared a vowel");
128
+ }
129
+ let vowelGroups = [];
130
+ if (rules["vowel-groups"] !== undefined) {
131
+ const block = rules["vowel-groups"];
132
+ closed(
133
+ block,
134
+ ["reading", "allow-splitting", "unrecognized-adjacent-vowels", "groups"],
135
+ "rules.vowel-groups",
136
+ );
137
+ exact(block.reading, "established", "rules.vowel-groups.reading");
138
+ exact(block["allow-splitting"], false, "rules.vowel-groups.allow-splitting");
139
+ exact(
140
+ block["unrecognized-adjacent-vowels"],
141
+ "separate",
142
+ "rules.vowel-groups.unrecognized-adjacent-vowels",
143
+ );
144
+ if (!Array.isArray(block.groups))
145
+ fault("rules.vowel-groups.groups", "List established vowel groups explicitly");
146
+ const seen = new Set();
147
+ vowelGroups = block.groups
148
+ .map((group, i) => {
149
+ const key = `rules.vowel-groups.groups.${i}`;
150
+ if (
151
+ !scalar(group) ||
152
+ [...group].length < 2 ||
153
+ group !== group.toLowerCase() ||
154
+ base(group) !== group ||
155
+ [...group].some((c) => !vowelSet.has(c)) ||
156
+ seen.has(group)
157
+ )
158
+ fault(
159
+ key,
160
+ "A vowel group is a unique lowercase sequence of declared unaccented vowels",
161
+ );
162
+ seen.add(group);
163
+ return group;
164
+ })
165
+ .sort((a, b) => [...b].length - [...a].length);
166
+ }
167
+ let stress;
168
+ if (rules.stress !== undefined) {
169
+ const block = rules.stress;
170
+ closed(
171
+ block,
172
+ [
173
+ "modifier",
174
+ "position",
175
+ "printing",
176
+ "effect",
177
+ "changes-sound",
178
+ "changes-length",
179
+ "vowel-group-position",
180
+ ],
181
+ "rules.stress",
182
+ );
183
+ for (const [name, value] of Object.entries({
184
+ position: "after-stressed-vowel",
185
+ printing: false,
186
+ effect: "stress-only",
187
+ "changes-sound": false,
188
+ "changes-length": false,
189
+ "vowel-group-position": "first-vowel",
190
+ }))
191
+ exact(block[name], value, `rules.stress.${name}`);
192
+ stress = resolve(block.modifier, "rules.stress.modifier");
193
+ if (stress !== "\u{E0114}")
194
+ fault("rules.stress.modifier", "Stress is the nonprinting U+E0114 modifier");
195
+ if (!vowels) fault("vowels", "Stress rules require declared vowels");
196
+ }
197
+ closed(data.mappings, Object.keys(map(data.mappings) ? data.mappings : {}), "mappings");
198
+ const validateSource = (from, key) => {
199
+ if (!scalar(from) || !from || from !== from.normalize("NFC"))
200
+ fault(key, "Mapping sources must be nonempty NFC Unicode scalar text");
201
+ if (/[\p{White_Space}⟦⟧]/u.test(from))
202
+ fault(key, "Whitespace and literal markers belong to the shared standard");
203
+ const decomposed = from.normalize("NFD");
204
+ if (rules.diaeresis?.allowed === false && decomposed.includes("\u0308"))
205
+ fault(key, "Diaeresis is forbidden");
206
+ if (rules["grave-accent"]?.allowed === false && decomposed.includes("\u0300"))
207
+ fault(key, "Grave accents are forbidden");
208
+ };
209
+ const validateOutput = (from, to, key) => {
210
+ if (/[\u{E010D}\u{E010E}\u{E0112}\u{E0113}\u{E0115}]/u.test(to))
211
+ fault(key, "Structural controls cannot be mapping outputs");
212
+ if (from.normalize("NFD").includes("\u0301") && stress) {
213
+ const plain = from.normalize("NFD").replaceAll("\u0301", "").normalize("NFC");
214
+ if (
215
+ [...from].length !== 1 ||
216
+ !vowelSet.has(base(from)) ||
217
+ !Object.hasOwn(data.mappings, plain) ||
218
+ to !== resolve(data.mappings[plain], key) + stress
219
+ )
220
+ fault(
221
+ key,
222
+ "An acute vowel must emit its unchanged vowel followed by the stress modifier",
223
+ );
224
+ } else if (stress && to.includes(stress))
225
+ fault(key, "The stress modifier belongs only after an acute vowel");
226
+ };
227
+ const mappings = [];
228
+ for (const [from, output] of Object.entries(data.mappings)) {
229
+ const key = `mappings.${from}`;
230
+ validateSource(from, key);
231
+ const to = resolve(output, key, canonical);
232
+ validateOutput(from, to, key);
233
+ mappings.push([from, to]);
234
+ }
235
+ const separators = [];
236
+ if (rules["word-separators"] !== undefined) {
237
+ if (!Array.isArray(rules["word-separators"]))
238
+ fault("rules.word-separators", "word-separators must be a list");
239
+ const seen = new Set();
240
+ for (const [i, from] of rules["word-separators"].entries()) {
241
+ const key = `rules.word-separators.${i}`;
242
+ validateSource(from, key);
243
+ if (
244
+ [...from].some(
245
+ (char) =>
246
+ "^/{}".includes(char) || /[\p{Cc}\p{Cf}\u{E0100}-\u{E01EF}]/u.test(char),
247
+ ) ||
248
+ STANDARD_ROWS.some((row) => (Array.isArray(row) ? row[0] : row.from) === from)
249
+ )
250
+ fault(
251
+ key,
252
+ "Word separators cannot replace shared punctuation or contain structural controls",
253
+ );
254
+ if (seen.has(from)) fault(key, "A word separator is stated twice");
255
+ if (Object.hasOwn(data.mappings, from))
256
+ fault(key, "A word separator cannot also have an ordinary mapping");
257
+ seen.add(from);
258
+ separators.push({ from, to: STANDARD_CONTROLS.wordSeparator });
259
+ }
260
+ }
261
+ const contextual = [];
262
+ if (rules["contextual-mappings"] !== undefined) {
263
+ if (!Array.isArray(rules["contextual-mappings"]))
264
+ fault("rules.contextual-mappings", "Contextual mappings must be a list");
265
+ const seen = new Set();
266
+ for (const [i, row] of rules["contextual-mappings"].entries()) {
267
+ const key = `rules.contextual-mappings.${i}`;
268
+ closed(row, ["match", "before", "at", "emit"], key);
269
+ if (!scalar(row.match) || !row.match || row.match !== row.match.normalize("NFC"))
270
+ fault(`${key}.match`, "A contextual match must be nonempty NFC text");
271
+ if (row.before === undefined && row.at === undefined)
272
+ fault(key, "A contextual mapping requires before or at");
273
+ if (row.before !== undefined && !["vowel", "consonant"].includes(row.before))
274
+ fault(`${key}.before`, "before is vowel or consonant");
275
+ if (row.at !== undefined && !["start", "end", "line-start"].includes(row.at))
276
+ fault(`${key}.at`, "at is start, end or line-start");
277
+ if (row.before && !vowels)
278
+ fault(
279
+ `${key}.before`,
280
+ "Before-vowel or before-consonant mappings require declared vowels",
281
+ );
282
+ const condition = JSON.stringify([row.match, row.before ?? null, row.at ?? null]);
283
+ if (seen.has(condition))
284
+ fault(
285
+ `${key}.match`,
286
+ "A contextual mapping repeats the same source and conditions",
287
+ );
288
+ validateSource(row.match, `${key}.match`);
289
+ const to = resolve(row.emit, `${key}.emit`, canonical);
290
+ validateOutput(row.match, to, `${key}.emit`);
291
+ seen.add(condition);
292
+ contextual.push({
293
+ from: row.match,
294
+ to,
295
+ ...(row.before !== undefined ? { before: row.before } : {}),
296
+ ...(row.at !== undefined ? { at: row.at } : {}),
297
+ });
298
+ }
299
+ }
300
+ if (rules.case)
301
+ for (const [from] of [...mappings, ...contextual.map((r) => [r.from])]) {
302
+ if ([...from].length < 2 || !/^\p{L}+$/u.test(from)) continue;
303
+ const lower = from.toLowerCase(),
304
+ initial = [...lower][0].toUpperCase() + [...lower].slice(1).join("");
305
+ if (from !== lower && from !== initial)
306
+ fault(`mappings.${from}`, "A group must be lowercase or initial uppercase");
307
+ }
308
+ const overridden = new Set(mappings.map(([from]) => from));
309
+ const rows = [
310
+ ...STANDARD_ROWS.filter((row) => !overridden.has(Array.isArray(row) ? row[0] : row.from)),
311
+ ...contextual,
312
+ ...mappings,
313
+ ...separators,
314
+ ];
315
+ const sourceKey = (row) => {
316
+ if (Array.isArray(row)) return `mappings.${row[0]}`;
317
+ const separator = separators.indexOf(row);
318
+ if (separator >= 0) return `rules.word-separators.${separator}`;
319
+ const context = contextual.indexOf(row);
320
+ return context < 0 ? "rules" : `rules.contextual-mappings.${context}`;
321
+ };
322
+ if (!canonical)
323
+ assertUniqueOutputs(
324
+ [
325
+ ...rows.map((row) =>
326
+ Array.isArray(row) ?
327
+ { from: row[0], to: row[1], key: sourceKey(row) }
328
+ : { ...row, key: sourceKey(row) },
329
+ ),
330
+ { from: " ", to: " ", key: "rules" },
331
+ ],
332
+ fault,
333
+ );
334
+ const accepts = data.accepts ?? [
335
+ ...new Set([
336
+ ...Object.keys(data.mappings).join(""),
337
+ ...contextual.map((r) => r.from).join(""),
338
+ ...separators.map((row) => row.from).join(""),
339
+ ]),
340
+ ];
341
+ if (!Array.isArray(accepts)) fault("accepts", "accepts must be a list of characters or ranges");
342
+ return {
343
+ definition: {
344
+ name: "encoding-note",
345
+ fonts: [data.font],
346
+ accepts: [...accepts, ...STANDARD_ROWS.map((r) => (Array.isArray(r) ? r[0] : r.from))],
347
+ separator: " ",
348
+ roundTrip: canonical ? "canonical" : "exact",
349
+ vowels,
350
+ rows,
351
+ codes: Object.entries(options.codes),
352
+ controls: STANDARD_CONTROLS,
353
+ ...(rules.case ? { groupCase: "initial" } : {}),
354
+ },
355
+ rowKeys: rows.map(sourceKey),
356
+ metadata: {
357
+ name: options.name ?? `${data.language}-${data.font}`,
358
+ language: data.language,
359
+ font: data.font,
360
+ template: Object.freeze({
361
+ normalization: "NFC",
362
+ vowelGroups: Object.freeze(vowelGroups),
363
+ maxVowelGroup: Math.max(0, ...vowelGroups.map((group) => [...group].length)),
364
+ stress,
365
+ diaeresis: rules.diaeresis?.allowed === false,
366
+ grave: rules["grave-accent"]?.allowed === false,
367
+ }),
368
+ },
369
+ };
370
+ }