@heroiclands/package-build 24.1.1 → 25.1.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +36 -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 +154 -11
  9. package/docs/authoring/links-and-markup.md +260 -473
  10. package/docs/commands.md +118 -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 +135 -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 +453 -569
  19. package/docs/reference/format-details.md +290 -380
  20. package/docs/reference/note-types.md +80 -62
  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 +21 -1
  25. package/engine/content-format.yaml +7 -2
  26. package/engine/content-index.mjs +6 -2
  27. package/engine/content-links.mjs +1 -0
  28. package/engine/content-scripts.mjs +658 -423
  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 +699 -0
  37. package/engine/encoding-notes.mjs +186 -0
  38. package/engine/encoding-standard.mjs +42 -0
  39. package/engine/encoding-template.mjs +313 -0
  40. package/engine/encoding-terms.mjs +141 -0
  41. package/engine/encoding.mjs +946 -0
  42. package/engine/extension-attributes.mjs +2 -0
  43. package/engine/helpers.mjs +1 -1
  44. package/engine/index.mjs +10 -0
  45. package/engine/language-data.mjs +4 -1
  46. package/engine/language-terms.mjs +23 -12
  47. package/engine/{logographic.mjs → language-writings.mjs} +29 -38
  48. package/engine/markup-validation.mjs +1 -1
  49. package/engine/note-vocabulary.mjs +4 -158
  50. package/engine/pdf-render.mjs +31 -3
  51. package/engine/roman-characters.mjs +232 -0
  52. package/engine/roman-run.mjs +149 -0
  53. package/engine/roman-verse.mjs +95 -0
  54. package/engine/run-rows.mjs +29 -31
  55. package/engine/run-words.mjs +2 -2
  56. package/engine/script-emphasis-terms.mjs +3 -0
  57. package/engine/script-emphasis.mjs +3 -10
  58. package/engine/script-font-delivery.mjs +9 -12
  59. package/engine/script-fonts.mjs +2 -1
  60. package/engine/site-build.mjs +14 -5
  61. package/engine/site-preview.mjs +12 -2
  62. package/engine/sql-tables.mjs +36 -31
  63. package/engine/verse-metre.mjs +1 -1
  64. package/engine/word-forms.mjs +1 -1
  65. package/engine/word-inflections.mjs +48 -0
  66. package/engine/word-rows.mjs +1 -1
  67. package/engine/word-terms.mjs +16 -17
  68. package/package.json +11 -5
  69. package/types/config.d.mts +5 -0
  70. package/types/convert.d.mts +44 -0
  71. package/types/e2e.d.mts +1 -1
  72. package/types/engine/base-compiler.d.mts +4 -2
  73. package/types/engine/content-charset.d.mts +8 -0
  74. package/types/engine/content-index.d.mts +4 -1
  75. package/types/engine/content-scripts.d.mts +108 -42
  76. package/types/engine/converter-data.d.mts +11 -0
  77. package/types/engine/dictionary-rows.d.mts +1 -25
  78. package/types/engine/encoding-integrity.d.mts +14 -0
  79. package/types/engine/encoding-language.d.mts +36 -0
  80. package/types/engine/encoding-lexicon.d.mts +259 -0
  81. package/types/engine/encoding-notes.d.mts +39 -0
  82. package/types/engine/encoding-standard.d.mts +11 -0
  83. package/types/engine/encoding-template.d.mts +43 -0
  84. package/types/engine/encoding-terms.d.mts +105 -0
  85. package/types/engine/encoding.d.mts +155 -0
  86. package/types/engine/helpers.d.mts +1 -1
  87. package/types/engine/index.d.mts +5 -0
  88. package/types/engine/language-terms.d.mts +1 -1
  89. package/types/engine/{logographic.d.mts → language-writings.d.mts} +6 -6
  90. package/types/engine/markup-validation.d.mts +1 -1
  91. package/types/engine/pdf-render.d.mts +1 -1
  92. package/types/engine/roman-characters.d.mts +45 -0
  93. package/types/engine/roman-run.d.mts +101 -0
  94. package/types/engine/roman-verse.d.mts +23 -0
  95. package/types/engine/run-rows.d.mts +13 -20
  96. package/types/engine/script-emphasis-terms.d.mts +2 -0
  97. package/types/engine/script-emphasis.d.mts +1 -1
  98. package/types/engine/script-font-delivery.d.mts +2 -3
  99. package/types/engine/script-fonts.d.mts +5 -0
  100. package/types/engine/site-preview.d.mts +1 -1
  101. package/types/engine/word-inflections.d.mts +25 -0
  102. package/engine/map-lexicon.mjs +0 -1034
  103. package/engine/text-maps.mjs +0 -654
  104. package/engine/written-in.mjs +0 -204
  105. package/types/engine/map-lexicon.d.mts +0 -330
  106. package/types/engine/text-maps.d.mts +0 -189
  107. 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,313 @@
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) => {
65
+ const sequence = typeof value === "string" ? [value] : value;
66
+ if (!Array.isArray(sequence) || !sequence.length)
67
+ fault(key, "A mapping is a glyph code or a nonempty sequence of glyph codes");
68
+ return sequence
69
+ .map((name) => {
70
+ if (
71
+ typeof name !== "string" ||
72
+ !codeName.test(name) ||
73
+ !Object.hasOwn(options.codes, name)
74
+ )
75
+ fault(key, `Unknown glyph code ${String(name)}`);
76
+ return options.codes[name];
77
+ })
78
+ .join("");
79
+ };
80
+ closed(
81
+ data.rules,
82
+ [
83
+ "normalization",
84
+ "matching",
85
+ "case",
86
+ "stress",
87
+ "vowel-groups",
88
+ "diaeresis",
89
+ "grave-accent",
90
+ "contextual-mappings",
91
+ "y",
92
+ ],
93
+ "rules",
94
+ );
95
+ const rules = data.rules;
96
+ exact(rules.normalization, "NFC", "rules.normalization");
97
+ exact(rules.matching, "longest-first", "rules.matching");
98
+ if (rules.case !== undefined)
99
+ fixed(
100
+ rules.case,
101
+ {
102
+ "single-letter": "preserve",
103
+ "consonant-group": "lowercase-or-initial-uppercase",
104
+ "other-group-cases": "forbidden",
105
+ },
106
+ "rules.case",
107
+ );
108
+ for (const key of ["diaeresis", "grave-accent"])
109
+ if (rules[key] !== undefined) fixed(rules[key], { allowed: false }, `rules.${key}`);
110
+ const vowels = data.vowels ?? "";
111
+ if (!scalar(vowels)) fault("vowels", "vowels must be a Unicode scalar string");
112
+ const vowelSet = new Set([...vowels].map(base));
113
+ if (rules.y !== undefined) {
114
+ fixed(rules.y, { kind: "consonant", sound: "y in yes", "vowel-reading": false }, "rules.y");
115
+ if (/[yY]/u.test(vowels)) fault("rules.y", "Consonant y cannot be declared a vowel");
116
+ }
117
+ let vowelGroups = [];
118
+ if (rules["vowel-groups"] !== undefined) {
119
+ const block = rules["vowel-groups"];
120
+ closed(
121
+ block,
122
+ ["reading", "allow-splitting", "unrecognized-adjacent-vowels", "groups"],
123
+ "rules.vowel-groups",
124
+ );
125
+ exact(block.reading, "established", "rules.vowel-groups.reading");
126
+ exact(block["allow-splitting"], false, "rules.vowel-groups.allow-splitting");
127
+ exact(
128
+ block["unrecognized-adjacent-vowels"],
129
+ "separate",
130
+ "rules.vowel-groups.unrecognized-adjacent-vowels",
131
+ );
132
+ if (!Array.isArray(block.groups))
133
+ fault("rules.vowel-groups.groups", "List established vowel groups explicitly");
134
+ const seen = new Set();
135
+ vowelGroups = block.groups
136
+ .map((group, i) => {
137
+ const key = `rules.vowel-groups.groups.${i}`;
138
+ if (
139
+ !scalar(group) ||
140
+ [...group].length < 2 ||
141
+ group !== group.toLowerCase() ||
142
+ base(group) !== group ||
143
+ [...group].some((c) => !vowelSet.has(c)) ||
144
+ seen.has(group)
145
+ )
146
+ fault(
147
+ key,
148
+ "A vowel group is a unique lowercase sequence of declared unaccented vowels",
149
+ );
150
+ seen.add(group);
151
+ return group;
152
+ })
153
+ .sort((a, b) => [...b].length - [...a].length);
154
+ }
155
+ let stress;
156
+ if (rules.stress !== undefined) {
157
+ const block = rules.stress;
158
+ closed(
159
+ block,
160
+ [
161
+ "modifier",
162
+ "position",
163
+ "printing",
164
+ "effect",
165
+ "changes-sound",
166
+ "changes-length",
167
+ "vowel-group-position",
168
+ ],
169
+ "rules.stress",
170
+ );
171
+ for (const [name, value] of Object.entries({
172
+ position: "after-stressed-vowel",
173
+ printing: false,
174
+ effect: "stress-only",
175
+ "changes-sound": false,
176
+ "changes-length": false,
177
+ "vowel-group-position": "first-vowel",
178
+ }))
179
+ exact(block[name], value, `rules.stress.${name}`);
180
+ stress = resolve(block.modifier, "rules.stress.modifier");
181
+ if (stress !== "\u{E0114}")
182
+ fault("rules.stress.modifier", "Stress is the nonprinting U+E0114 modifier");
183
+ if (!vowels) fault("vowels", "Stress rules require declared vowels");
184
+ }
185
+ closed(data.mappings, Object.keys(map(data.mappings) ? data.mappings : {}), "mappings");
186
+ const validateSource = (from, key) => {
187
+ if (!scalar(from) || !from || from !== from.normalize("NFC"))
188
+ fault(key, "Mapping sources must be nonempty NFC Unicode scalar text");
189
+ if (/[\p{White_Space}⟦⟧]/u.test(from))
190
+ fault(key, "Whitespace and literal markers belong to the shared standard");
191
+ const decomposed = from.normalize("NFD");
192
+ if (rules.diaeresis && decomposed.includes("\u0308")) fault(key, "Diaeresis is forbidden");
193
+ if (rules["grave-accent"] && decomposed.includes("\u0300"))
194
+ fault(key, "Grave accents are forbidden");
195
+ };
196
+ const validateOutput = (from, to, key) => {
197
+ if (/[\u{E010D}\u{E010E}\u{E0112}\u{E0113}\u{E0115}]/u.test(to))
198
+ fault(key, "Structural controls cannot be mapping outputs");
199
+ if (from.normalize("NFD").includes("\u0301") && stress) {
200
+ const plain = from.normalize("NFD").replaceAll("\u0301", "").normalize("NFC");
201
+ if (
202
+ [...from].length !== 1 ||
203
+ !vowelSet.has(base(from)) ||
204
+ !Object.hasOwn(data.mappings, plain) ||
205
+ to !== resolve(data.mappings[plain], key) + stress
206
+ )
207
+ fault(
208
+ key,
209
+ "An acute vowel must emit its unchanged vowel followed by the stress modifier",
210
+ );
211
+ } else if (stress && to.includes(stress))
212
+ fault(key, "The stress modifier belongs only after an acute vowel");
213
+ };
214
+ const mappings = [];
215
+ for (const [from, output] of Object.entries(data.mappings)) {
216
+ const key = `mappings.${from}`;
217
+ validateSource(from, key);
218
+ const to = resolve(output, key);
219
+ validateOutput(from, to, key);
220
+ mappings.push([from, to]);
221
+ }
222
+ const contextual = [];
223
+ if (rules["contextual-mappings"] !== undefined) {
224
+ if (!Array.isArray(rules["contextual-mappings"]))
225
+ fault("rules.contextual-mappings", "Contextual mappings must be a list");
226
+ const seen = new Set();
227
+ for (const [i, row] of rules["contextual-mappings"].entries()) {
228
+ const key = `rules.contextual-mappings.${i}`;
229
+ closed(row, ["match", "before", "emit"], key);
230
+ if (
231
+ !scalar(row.match) ||
232
+ !row.match ||
233
+ row.match !== row.match.normalize("NFC") ||
234
+ seen.has(row.match)
235
+ )
236
+ fault(`${key}.match`, "A contextual match must be unique nonempty NFC text");
237
+ exact(row.before, "vowel", `${key}.before`);
238
+ if (!vowels) fault(`${key}.before`, "Before-vowel mappings require declared vowels");
239
+ validateSource(row.match, `${key}.match`);
240
+ const to = resolve(row.emit, `${key}.emit`);
241
+ validateOutput(row.match, to, `${key}.emit`);
242
+ seen.add(row.match);
243
+ contextual.push({ from: row.match, to, before: "vowel" });
244
+ }
245
+ }
246
+ if (rules.case)
247
+ for (const [from] of [...mappings, ...contextual.map((r) => [r.from])]) {
248
+ if ([...from].length < 2 || !/^\p{L}+$/u.test(from)) continue;
249
+ const lower = from.toLowerCase(),
250
+ initial = [...lower][0].toUpperCase() + [...lower].slice(1).join("");
251
+ if (from !== lower && from !== initial)
252
+ fault(`mappings.${from}`, "A group must be lowercase or initial uppercase");
253
+ }
254
+ const overridden = new Set(mappings.map(([from]) => from));
255
+ const rows = [
256
+ ...STANDARD_ROWS.filter((row) => !overridden.has(Array.isArray(row) ? row[0] : row.from)),
257
+ ...contextual,
258
+ ...mappings,
259
+ ];
260
+ assertUniqueOutputs(
261
+ [
262
+ ...rows.map((row) => {
263
+ if (Array.isArray(row))
264
+ return { from: row[0], to: row[1], key: `mappings.${row[0]}` };
265
+ const index = contextual.indexOf(row);
266
+ return {
267
+ ...row,
268
+ key: index < 0 ? "rules" : `rules.contextual-mappings.${index}.emit`,
269
+ };
270
+ }),
271
+ { from: " ", to: " ", key: "rules" },
272
+ ],
273
+ fault,
274
+ );
275
+ const accepts = data.accepts ?? [
276
+ ...new Set([
277
+ ...Object.keys(data.mappings).join(""),
278
+ ...contextual.map((r) => r.from).join(""),
279
+ ]),
280
+ ];
281
+ if (!Array.isArray(accepts)) fault("accepts", "accepts must be a list of characters or ranges");
282
+ return {
283
+ definition: {
284
+ name: "encoding-note",
285
+ fonts: [data.font],
286
+ accepts: [...accepts, ...STANDARD_ROWS.map((r) => (Array.isArray(r) ? r[0] : r.from))],
287
+ separator: " ",
288
+ vowels,
289
+ rows,
290
+ codes: Object.entries(options.codes),
291
+ controls: STANDARD_CONTROLS,
292
+ ...(rules.case ? { groupCase: "initial" } : {}),
293
+ },
294
+ rowKeys: rows.map((row) => {
295
+ if (Array.isArray(row)) return `mappings.${row[0]}`;
296
+ const index = contextual.indexOf(row);
297
+ return index < 0 ? "rules" : `rules.contextual-mappings.${index}`;
298
+ }),
299
+ metadata: {
300
+ name: options.name ?? `${data.language}-${data.font}`,
301
+ language: data.language,
302
+ font: data.font,
303
+ template: Object.freeze({
304
+ normalization: "NFC",
305
+ vowelGroups: Object.freeze(vowelGroups),
306
+ maxVowelGroup: Math.max(0, ...vowelGroups.map((group) => [...group].length)),
307
+ stress,
308
+ diaeresis: !!rules.diaeresis,
309
+ grave: !!rules["grave-accent"],
310
+ }),
311
+ },
312
+ };
313
+ }
@@ -0,0 +1,141 @@
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
+ /** Frontmatter shapes for executable encoding documents. */
15
+ const text = (name, describe, required = false) => ({
16
+ name,
17
+ kind: "string",
18
+ shape: "a string",
19
+ describe,
20
+ required,
21
+ });
22
+ const fixed = (name, value) => ({
23
+ ...text(name, `Required value: ${JSON.stringify(value)}.`, true),
24
+ kind: typeof value === "boolean" ? "boolean" : "string",
25
+ shape: JSON.stringify(value),
26
+ ...(typeof value === "string" ? { allowed: [value] } : {}),
27
+ });
28
+ const block = (name, describe, fields) => ({ name, kind: "map", shape: "a map", describe, fields });
29
+ const sequence = {
30
+ kind: "string-or-list",
31
+ shape: "a glyph code or list of codes",
32
+ entries: {
33
+ kind: "string",
34
+ shape: "a glyph code",
35
+ describe: "A code published by the selected font.",
36
+ },
37
+ };
38
+ /** Closed nested keys, also documented by the generated note reference. */
39
+ export const ENCODING_RULE_FIELDS = Object.freeze([
40
+ fixed("normalization", "NFC"),
41
+ fixed("matching", "longest-first"),
42
+ block("case", "Case handling for letters and consonant groups.", [
43
+ fixed("single-letter", "preserve"),
44
+ fixed("consonant-group", "lowercase-or-initial-uppercase"),
45
+ fixed("other-group-cases", "forbidden"),
46
+ ]),
47
+ block("stress", "The font's nonprinting stress modifier after an acute vowel.", [
48
+ text("modifier", "The font code for U+E0114.", true),
49
+ fixed("position", "after-stressed-vowel"),
50
+ fixed("printing", false),
51
+ fixed("effect", "stress-only"),
52
+ fixed("changes-sound", false),
53
+ fixed("changes-length", false),
54
+ fixed("vowel-group-position", "first-vowel"),
55
+ ]),
56
+ block("vowel-groups", "Explicit recognized vowel groups and their acute placement.", [
57
+ fixed("reading", "established"),
58
+ fixed("allow-splitting", false),
59
+ fixed("unrecognized-adjacent-vowels", "separate"),
60
+ {
61
+ name: "groups",
62
+ kind: "list",
63
+ shape: "a list of vowel groups",
64
+ required: true,
65
+ entries: {
66
+ kind: "string",
67
+ shape: "a lowercase vowel sequence",
68
+ describe: "One recognized sequence of declared unaccented vowels.",
69
+ },
70
+ describe: "The complete authored group inventory.",
71
+ },
72
+ ]),
73
+ block("diaeresis", "Whether diaeresis mappings are allowed.", [fixed("allowed", false)]),
74
+ block("grave-accent", "Whether grave-accent mappings are allowed.", [fixed("allowed", false)]),
75
+ {
76
+ name: "contextual-mappings",
77
+ kind: "list",
78
+ shape: "a list of conditional mappings",
79
+ describe: "Before-vowel alternatives applied before unconditional mappings.",
80
+ entries: {
81
+ kind: "map",
82
+ shape: "a contextual mapping",
83
+ fields: [
84
+ text("match", "The source sequence.", true),
85
+ fixed("before", "vowel"),
86
+ {
87
+ name: "emit",
88
+ ...sequence,
89
+ required: true,
90
+ describe: "The font codes written by this alternative.",
91
+ },
92
+ ],
93
+ },
94
+ },
95
+ block("y", "The consonant reading of y.", [
96
+ fixed("kind", "consonant"),
97
+ fixed("sound", "y in yes"),
98
+ fixed("vowel-reading", false),
99
+ ]),
100
+ ]);
101
+ /** The additional data keys a document may use to define its encoding. */
102
+ export const ENCODING_NOTE_FIELDS = Object.freeze([
103
+ {
104
+ ...text("language", "The configured language alias an encoding document writes."),
105
+ shape: "a configured language alias",
106
+ },
107
+ {
108
+ ...text("font", "The encoding's installed font, which supplies all glyph codes."),
109
+ shape: "an installed font slug",
110
+ },
111
+ {
112
+ name: "accepts",
113
+ kind: "list",
114
+ shape: "a list of characters or ranges",
115
+ entries: {
116
+ kind: "string",
117
+ shape: "characters or a range",
118
+ describe: "Accepted Roman characters or a range.",
119
+ },
120
+ describe: "Accepted Roman letters and additions to standard punctuation.",
121
+ },
122
+ text("vowels", "The letters a contextual vowel match reads as vowels."),
123
+ {
124
+ name: "mappings",
125
+ kind: "map",
126
+ shape: "Roman strings to glyph codes",
127
+ values: {
128
+ ...sequence,
129
+ describe: "The font code or sequence written for this Roman string.",
130
+ },
131
+ describe: "Roman source strings mapped to codes published by the selected font.",
132
+ },
133
+ {
134
+ name: "rules",
135
+ kind: "map",
136
+ shape: "the closed encoding rules vocabulary",
137
+ fields: ENCODING_RULE_FIELDS,
138
+ describe:
139
+ "Executable normalization, matching, case, stress, vowel groups, accents and contextual mappings.",
140
+ },
141
+ ]);