@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,946 @@
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
+ /** Pure encoding-note compiler and script execution. No files or Node APIs are needed. */
15
+ import { templateProgram } from "./encoding-template.mjs";
16
+ const OPEN = "⟦";
17
+ const CLOSE = "⟧";
18
+ const OPEN_MARK = "\u{E0112}";
19
+ const CLOSE_MARK = "\u{E0113}";
20
+ const ESCAPE_MARK = "\u{E0115}";
21
+ const WHITE = /^[\p{Zs}\u0009-\u000D]$/u;
22
+ const SPACE = /^\p{White_Space}$/u;
23
+ const LETTER = /^\p{L}$/u;
24
+ const WORD = /^[\p{L}\p{N}_]$/u;
25
+ const BOUNDARY = /^[\p{P}\p{S}]$/u;
26
+ const SLUG = /^[a-z0-9-]+$/;
27
+ const TOP_KEYS = new Set([
28
+ "name",
29
+ "kind",
30
+ "fonts",
31
+ "codes",
32
+ "controls",
33
+ "accepts",
34
+ "separator",
35
+ "vowels",
36
+ "rows",
37
+ "faces",
38
+ "roundTrip",
39
+ "groupCase",
40
+ ]);
41
+ const CONTROL_KEYS = new Set([
42
+ "verseStanza",
43
+ "verseCanto",
44
+ "wordSeparator",
45
+ "literalOpen",
46
+ "literalClose",
47
+ "literalEscape",
48
+ ]);
49
+ const compiled = new WeakSet();
50
+ const templateReaders = new WeakMap();
51
+ const templateSources = new WeakMap();
52
+ const chars = (text) => [...text];
53
+ const length = (text) => chars(text).length;
54
+ const firstAt = (text, at) =>
55
+ at < text.length ? String.fromCodePoint(text.codePointAt(at)) : undefined;
56
+ const previousAt = (text, at) => {
57
+ const last = text.charCodeAt(at - 1);
58
+ return (
59
+ at > 1 &&
60
+ last >= 0xdc00 &&
61
+ last <= 0xdfff &&
62
+ text.charCodeAt(at - 2) >= 0xd800 &&
63
+ text.charCodeAt(at - 2) <= 0xdbff
64
+ ) ?
65
+ text.slice(at - 2, at)
66
+ : text.slice(at - 1, at);
67
+ };
68
+ function pointSlice(text, at, count) {
69
+ let end = at;
70
+ while (count-- > 0 && end < text.length) end += firstAt(text, end).length;
71
+ return text.slice(at, end);
72
+ }
73
+ const scalar = (text) =>
74
+ !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(text);
75
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
76
+ const wordy = (char) => char !== undefined && WORD.test(char);
77
+
78
+ /**
79
+ * A schema fault or text refusal. Text offsets are UTF-16; columns count code points.
80
+ */
81
+ export class EncodingError extends Error {
82
+ /** @param {string} message @param {{text?: string, offset?: number, key?: string}} [details] */
83
+ constructor(message, { text, offset = 0, key } = {}) {
84
+ super(message);
85
+ this.name = "EncodingError";
86
+ if (key !== undefined) this.key = key;
87
+ if (text !== undefined) {
88
+ this.offset = offset;
89
+ this.line = text.slice(0, offset).split("\n").length;
90
+ this.column = length(text.slice(text.lastIndexOf("\n", offset - 1) + 1, offset)) + 1;
91
+ }
92
+ }
93
+ }
94
+
95
+ function fault(key, message) {
96
+ throw new EncodingError(message, { key });
97
+ }
98
+ function textError(text, offset, message) {
99
+ throw new EncodingError(message, { text, offset });
100
+ }
101
+ function keys(value, allowed, at) {
102
+ for (const key of Object.keys(value))
103
+ if (!allowed.has(key))
104
+ fault(
105
+ at ? `${at}.${key}` : key,
106
+ `Unknown key \`${key}\`; an encoding is self-contained`,
107
+ );
108
+ }
109
+ function string(value, key, nonempty = false) {
110
+ if (typeof value !== "string" || (nonempty && !value) || !scalar(value))
111
+ fault(key, `\`${key}\` must be ${nonempty ? "a nonempty" : "a"} Unicode scalar string`);
112
+ }
113
+ function rows(value, key, vowels, allowEmpty) {
114
+ if (!Array.isArray(value)) fault(key, `\`${key}\` must be a list of rows`);
115
+ return value.map((value, order) => {
116
+ const at = `${key}.${order}`;
117
+ let from, to, before, position;
118
+ if (Array.isArray(value)) {
119
+ if (value.length !== 2) fault(at, "A row is two strings, [from, to]");
120
+ [from, to] = value;
121
+ } else if (object(value)) {
122
+ keys(value, new Set(["from", "to", "before", "at"]), at);
123
+ ({ from, to, before, at: position } = value);
124
+ } else fault(at, "A row is [from, to] or {from, to, before?, at?}");
125
+ string(from, `${at}.from`, true);
126
+ string(to, `${at}.to`);
127
+ if (from.includes(OPEN) || from.includes(CLOSE))
128
+ fault(`${at}.from`, "Literal markers enclose codes and cannot be row sources");
129
+ if (from !== " " && chars(from).some((c) => WHITE.test(c)))
130
+ fault(`${at}.from`, "Whitespace is stated by separator");
131
+ if (!to && !allowEmpty && from !== " ")
132
+ fault(`${at}.to`, "An exact row must write a nonempty output");
133
+ if (before !== undefined && !["vowel", "consonant"].includes(before))
134
+ fault(`${at}.before`, "before is vowel or consonant");
135
+ if (before && !vowels) fault(`${at}.before`, "before needs vowels");
136
+ if (position !== undefined && !["start", "end", "line-start"].includes(position))
137
+ fault(`${at}.at`, "at is start, end or line-start");
138
+ return Object.freeze({ from, to, before, at: position, order, key: at });
139
+ });
140
+ }
141
+ function indexRows(rows) {
142
+ const index = new Map();
143
+ for (const row of rows) {
144
+ const first = chars(row.from)[0];
145
+ if (!index.has(first)) index.set(first, []);
146
+ index.get(first).push(row);
147
+ }
148
+ for (const group of index.values())
149
+ group.sort(
150
+ (a, b) =>
151
+ length(b.from) - length(a.from) ||
152
+ Number(Boolean(b.before || b.at)) - Number(Boolean(a.before || a.at)) ||
153
+ a.order - b.order,
154
+ );
155
+ return index;
156
+ }
157
+ function readings(rows) {
158
+ const seen = new Set();
159
+ const out = new Map();
160
+ for (const row of rows) {
161
+ if (!row.to || seen.has(row.to)) continue;
162
+ seen.add(row.to);
163
+ const first = chars(row.to)[0];
164
+ if (!out.has(first)) out.set(first, []);
165
+ out.get(first).push(row);
166
+ }
167
+ for (const group of out.values()) group.sort((a, b) => length(b.to) - length(a.to));
168
+ return out;
169
+ }
170
+ function tier(rows, shared = [], prefix = []) {
171
+ return { rows, index: indexRows(rows), readings: readings([...prefix, ...rows, ...shared]) };
172
+ }
173
+
174
+ /**
175
+ * Validate and compile an encoding note with a separately resolved font code table.
176
+ * @param {any} data Encoding-note data with mappings and executable rules.
177
+ * @param {{codes?: Record<string,string>, name?: string}} [options] Font glyph codes and canonical note identity.
178
+ */
179
+ export function compileEncoding(data, options = {}) {
180
+ const program = templateProgram(data, options, fault);
181
+ try {
182
+ return compileRows(program.definition, program.metadata);
183
+ } catch (error) {
184
+ const row = error instanceof EncodingError && error.key?.match(/^rows\.(\d+)(?:\.|$)/u);
185
+ if (row) error.key = program.rowKeys[Number(row[1])];
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Compile the internal row program, including isolated execution conformance fixtures.
192
+ * Application inputs go through compileEncoding's closed note schema.
193
+ * @internal
194
+ * @param {any} definition Fully resolved internal rows.
195
+ * @param {Partial<ReturnType<typeof templateProgram>["metadata"]>} [metadata] Validated note metadata, supplied by the template compiler.
196
+ */
197
+ export function compileRows(definition, metadata = {}) {
198
+ if (!object(definition)) fault("", "An encoding must be an object");
199
+ keys(definition, TOP_KEYS, "");
200
+ const d = definition;
201
+ if (typeof d.name !== "string" || !SLUG.test(d.name))
202
+ fault("name", "name must be a lowercase slug");
203
+ if (d.kind !== undefined && d.kind !== "encoding")
204
+ fault("kind", "Only an encoding can encode text; codes belong inside its codes rows");
205
+ if (!Array.isArray(d.fonts) || !d.fonts.length)
206
+ fault("fonts", "fonts must name at least one face");
207
+ for (const [i, font] of d.fonts.entries())
208
+ if (
209
+ typeof font !== "string" ||
210
+ !SLUG.test(font) ||
211
+ ["mono", "serif", "sans"].includes(font) ||
212
+ d.fonts.indexOf(font) !== i
213
+ )
214
+ fault(`fonts.${i}`, "A face must be a unique font slug");
215
+ if (!Array.isArray(d.accepts))
216
+ fault("accepts", "accepts must be a list of characters or ranges");
217
+ const accepted = new Set();
218
+ for (const [i, item] of d.accepts.entries()) {
219
+ string(item, `accepts.${i}`, true);
220
+ const c = chars(item);
221
+ if (c.length === 1) accepted.add(item);
222
+ else if (c.length === 3 && c[1] === "-" && c[0].codePointAt(0) < c[2].codePointAt(0)) {
223
+ for (let point = c[0].codePointAt(0); point <= c[2].codePointAt(0); point++) {
224
+ if (point >= 0xd800 && point <= 0xdfff)
225
+ fault(`accepts.${i}`, "A range cannot contain surrogates");
226
+ accepted.add(String.fromCodePoint(point));
227
+ }
228
+ } else
229
+ fault(
230
+ `accepts.${i}`,
231
+ "An accepted item is one character or an ascending range such as a-z",
232
+ );
233
+ }
234
+ string(d.separator, "separator");
235
+ if (/[\t\n\r⟦⟧]/u.test(d.separator))
236
+ fault("separator", "separator cannot contain a line break or literal marker");
237
+ const vowels = d.vowels ?? "";
238
+ string(vowels, "vowels");
239
+ if (d.roundTrip !== undefined && !["exact", "canonical"].includes(d.roundTrip))
240
+ fault("roundTrip", "roundTrip is exact or canonical");
241
+ if (d.groupCase !== undefined && d.groupCase !== "initial")
242
+ fault("groupCase", "groupCase is initial");
243
+ const controls = d.controls ?? {};
244
+ if (!object(controls)) fault("controls", "controls must be an object");
245
+ keys(controls, CONTROL_KEYS, "controls");
246
+ for (const [key, value] of Object.entries(controls)) string(value, `controls.${key}`, true);
247
+ if (controls.literalOpen !== undefined && controls.literalOpen !== OPEN_MARK)
248
+ fault("controls.literalOpen", "literalOpen must be U+E0112");
249
+ if (controls.literalClose !== undefined && controls.literalClose !== CLOSE_MARK)
250
+ fault("controls.literalClose", "literalClose must be U+E0113");
251
+ if (controls.literalEscape !== undefined && controls.literalEscape !== ESCAPE_MARK)
252
+ fault("controls.literalEscape", "literalEscape must be U+E0115");
253
+ const sharedRows = rows(d.rows, "rows", vowels, d.roundTrip === "canonical");
254
+ const codeRows = rows(d.codes ?? [], "codes", "", false);
255
+ const separatorRow = { from: " ", to: d.separator, key: "separator" };
256
+ const prefix = d.separator ? [separatorRow] : [];
257
+ const controlRows = [];
258
+ if (controls.verseStanza) controlRows.push({ from: "\n\n", to: controls.verseStanza });
259
+ if (controls.verseCanto) controlRows.push({ from: "--- {.canto}", to: controls.verseCanto });
260
+ const shared = tier(sharedRows, [], [...prefix, ...controlRows]);
261
+ const faces = new Map();
262
+ if (d.faces !== undefined && !object(d.faces))
263
+ fault("faces", "faces must map face slugs to rows");
264
+ for (const [font, face] of Object.entries(d.faces ?? {})) {
265
+ if (!d.fonts.includes(font)) fault(`faces.${font}`, "Face must be listed in fonts");
266
+ if (!object(face)) fault(`faces.${font}`, "A face is {rows: [...]} ");
267
+ keys(face, new Set(["rows"]), `faces.${font}`);
268
+ faces.set(
269
+ font,
270
+ tier(
271
+ rows(face.rows ?? [], `faces.${font}.rows`, vowels, d.roundTrip === "canonical"),
272
+ sharedRows,
273
+ prefix,
274
+ ),
275
+ );
276
+ }
277
+ if (d.roundTrip !== "canonical") {
278
+ for (const group of [
279
+ sharedRows,
280
+ ...[...faces.values()].map((face) => [...sharedRows, ...face.rows]),
281
+ ]) {
282
+ const outputs = new Map(prefix.map((row) => [row.to, row]));
283
+ for (const row of group) {
284
+ if (outputs.has(row.to) && outputs.get(row.to).from !== row.from)
285
+ fault(
286
+ row.key,
287
+ `Output duplicates ${outputs.get(row.to).key}; exact encodings need distinct outputs`,
288
+ );
289
+ if (!outputs.has(row.to)) outputs.set(row.to, row);
290
+ }
291
+ }
292
+ }
293
+ const result = {
294
+ name: d.name,
295
+ fonts: Object.freeze([...d.fonts]),
296
+ separator: d.separator,
297
+ separatorRow,
298
+ vowels: new Set(chars(vowels)),
299
+ accepted,
300
+ controls: Object.freeze({ ...controls }),
301
+ groupCase: d.groupCase,
302
+ roundTrip: d.roundTrip ?? "exact",
303
+ shared,
304
+ faces,
305
+ codes: tier(codeRows),
306
+ ...metadata,
307
+ };
308
+ compiled.add(result);
309
+ return Object.freeze(result);
310
+ }
311
+ function tiers(enc, font) {
312
+ if (!compiled.has(enc))
313
+ throw new EncodingError("Compile the encoding definition before using it");
314
+ font ??= enc.fonts[0];
315
+ if (!enc.fonts.includes(font))
316
+ throw new EncodingError(`\`${enc.name}\` serves ${enc.fonts.join(", ")}, not \`${font}\``, {
317
+ key: "font",
318
+ });
319
+ return enc.faces.has(font) ? [enc.faces.get(font), enc.shared] : [enc.shared];
320
+ }
321
+ function boundary(char) {
322
+ // Python str.isspace also includes the four information separators.
323
+ return SPACE.test(char) || /[\u001C-\u001F]/u.test(char) || BOUNDARY.test(char);
324
+ }
325
+ function holds(row, text, start, end, vowels) {
326
+ if (row.before) {
327
+ const char = firstAt(text, end);
328
+ if (
329
+ !char ||
330
+ (row.before === "vowel" ? !vowels.has(char) : vowels.has(char) || !LETTER.test(char))
331
+ )
332
+ return false;
333
+ }
334
+ if (row.at === "line-start") {
335
+ const prefix = text.slice(
336
+ Math.max(text.lastIndexOf("\n", start - 1), text.lastIndexOf("/", start - 1)) + 1,
337
+ start,
338
+ );
339
+ if (prefix.replaceAll("^", "")) return false;
340
+ }
341
+ if (row.at === "start" && start > 0 && !boundary(previousAt(text, start))) return false;
342
+ if (row.at === "end" && end < text.length && !boundary(firstAt(text, end))) return false;
343
+ return true;
344
+ }
345
+ function rowAt(enc, groups, text, at, bound = text.length) {
346
+ const first = firstAt(text, at);
347
+ for (const group of groups)
348
+ for (const row of group.index.get(first) ?? [])
349
+ if (
350
+ at + row.from.length <= bound &&
351
+ text.startsWith(row.from, at) &&
352
+ holds(row, text, at, at + row.from.length, enc.vowels)
353
+ )
354
+ return row;
355
+ return null;
356
+ }
357
+ function refused(enc, text, at, known, decoding = false) {
358
+ const char = firstAt(text, at);
359
+ const shown = `${JSON.stringify(char)} (U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`;
360
+ textError(
361
+ text,
362
+ at,
363
+ known ?
364
+ `No row of the \`${enc.name}\` encoding ${decoding ? "writes" : "matches"} ${shown} here`
365
+ : `${shown} is ${decoding ? "written by no row of" : "not in"} the \`${enc.name}\` encoding`,
366
+ );
367
+ }
368
+ function span(text, at, opener, closer) {
369
+ let cursor = at + opener.length;
370
+ while (cursor < text.length) {
371
+ if (opener === OPEN_MARK && text.startsWith(ESCAPE_MARK, cursor)) {
372
+ const following = firstAt(text, cursor + ESCAPE_MARK.length);
373
+ if (![OPEN_MARK, CLOSE_MARK, ESCAPE_MARK].includes(following))
374
+ textError(
375
+ text,
376
+ cursor,
377
+ "A literal escape must quote its open, close or escape control",
378
+ );
379
+ cursor += ESCAPE_MARK.length + following.length;
380
+ continue;
381
+ }
382
+ if (text.startsWith(closer, cursor)) return cursor;
383
+ if (text.startsWith(opener, cursor))
384
+ textError(text, cursor, "A literal span opens inside another");
385
+ cursor += firstAt(text, cursor).length;
386
+ }
387
+ textError(text, at, "A literal span opens with no closing marker");
388
+ }
389
+ function unicodeCode(code) {
390
+ if (!/^U\+[0-9A-F]{4,6}$/.test(code)) return null;
391
+ const value = Number.parseInt(code.slice(2), 16);
392
+ return value <= 0x10ffff && !(value >= 0xd800 && value <= 0xdfff) ?
393
+ String.fromCodePoint(value)
394
+ : null;
395
+ }
396
+ function piece(from, to, start, end, row) {
397
+ return { from, to, start, end, row };
398
+ }
399
+ function codePieces(enc, text, start, end) {
400
+ const out = [];
401
+ let at = start;
402
+ while (at < end) {
403
+ const first = firstAt(text, at);
404
+ if (WHITE.test(first)) {
405
+ let stop = at + first.length;
406
+ while (stop < end && WHITE.test(firstAt(text, stop)))
407
+ stop += firstAt(text, stop).length;
408
+ out.push(piece(text.slice(at, stop), "", at, stop, null));
409
+ at = stop;
410
+ continue;
411
+ }
412
+ if (text.startsWith("U+", at)) {
413
+ let stop = at + 2;
414
+ while (stop < end && /^[\p{L}\p{N}+]$/u.test(firstAt(text, stop)))
415
+ stop += firstAt(text, stop).length;
416
+ const code = text.slice(at, stop);
417
+ const target = unicodeCode(code);
418
+ if (target === null)
419
+ textError(
420
+ text,
421
+ at,
422
+ `\`${code}\` is not a Unicode scalar code (U+0000 to U+10FFFF)`,
423
+ );
424
+ out.push(piece(code, target, at, stop, null));
425
+ at = stop;
426
+ continue;
427
+ }
428
+ const row = rowAt(enc, [enc.codes], text, at);
429
+ if (row) {
430
+ out.push(piece(row.from, row.to, at, at + row.from.length, row));
431
+ at += row.from.length;
432
+ continue;
433
+ }
434
+ let begin = at;
435
+ if (wordy(first))
436
+ while (begin > start && wordy(previousAt(text, begin)))
437
+ begin -= previousAt(text, begin).length;
438
+ textError(text, begin, "This code is not defined by the encoding");
439
+ }
440
+ return out;
441
+ }
442
+
443
+ function checkGroupCase(enc, groups, text, at, bound) {
444
+ if (enc.groupCase === "initial") {
445
+ const invalid = groups
446
+ .flatMap((group) => group.rows)
447
+ .map((row) => ({ row, value: pointSlice(text, at, length(row.from)) }))
448
+ .find(
449
+ ({ row, value }) =>
450
+ at + row.from.length <= bound &&
451
+ length(row.from) > 1 &&
452
+ /^\p{L}+$/u.test(row.from) &&
453
+ value.toLowerCase() === row.from.toLowerCase() &&
454
+ value !== row.from.toLowerCase() &&
455
+ value !==
456
+ chars(row.from.toLowerCase())[0].toUpperCase() +
457
+ chars(row.from.toLowerCase()).slice(1).join(""),
458
+ );
459
+ if (invalid)
460
+ textError(
461
+ text,
462
+ at,
463
+ `\`${invalid.value}\` is an illegal group spelling; use lowercase or initial capital`,
464
+ );
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Encode into matched pieces; start/end are offsets in the original UTF-16 input.
470
+ * Each piece has from, to, start, end and its matched row (null for literal syntax).
471
+ * @param {ReturnType<typeof compileEncoding>} enc
472
+ * @param {string} text
473
+ * @param {{font?: string, verse?: boolean, start?: number, end?: number}} [options]
474
+ */
475
+ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text.length } = {}) {
476
+ const groups = tiers(enc, font);
477
+ if (typeof text !== "string") throw new EncodingError("Text must be a string");
478
+ if (
479
+ !Number.isInteger(start) ||
480
+ !Number.isInteger(end) ||
481
+ start < 0 ||
482
+ end < start ||
483
+ end > text.length
484
+ )
485
+ throw new EncodingError("Encoding bounds must be ordered UTF-16 offsets within the input", {
486
+ key: "start",
487
+ });
488
+ const bound = end;
489
+ const out = [];
490
+ let at = start;
491
+ while (at < bound) {
492
+ const first = firstAt(text, at);
493
+ if (first === OPEN) {
494
+ const close = span(text.slice(0, bound), at, OPEN, CLOSE);
495
+ out.push(
496
+ piece(OPEN, OPEN_MARK, at, at + 1, null),
497
+ ...codePieces(enc, text, at + 1, close).map((one) => ({
498
+ ...one,
499
+ to: chars(one.to)
500
+ .map((char) =>
501
+ [OPEN_MARK, CLOSE_MARK, ESCAPE_MARK].includes(char) ?
502
+ ESCAPE_MARK + char
503
+ : char,
504
+ )
505
+ .join(""),
506
+ })),
507
+ piece(CLOSE, CLOSE_MARK, close, close + 1, null),
508
+ );
509
+ at = close + 1;
510
+ continue;
511
+ }
512
+ if (first === CLOSE) textError(text, at, "A closing literal marker has no opening marker");
513
+ const canto = cantoEnd(enc, text, at, bound);
514
+ if (canto !== undefined) {
515
+ const end = canto;
516
+ out.push(piece(text.slice(at, end), enc.controls.verseCanto, at, end, null));
517
+ at = end;
518
+ if (verse) {
519
+ if (text.startsWith("\r\n", at)) at += 2;
520
+ else if (/[\r\n]/.test(text[at] ?? "")) at++;
521
+ }
522
+ continue;
523
+ }
524
+ if (verse && /[\r\n]/.test(first) && enc.controls.verseStanza) {
525
+ let end = at;
526
+ while (end < bound && /[ \t\r\n]/.test(text[end])) end++;
527
+ const run = text.slice(at, end).replaceAll("\r\n", "\n").replaceAll("\r", "\n");
528
+ if (text.startsWith("--- {.canto}", end)) {
529
+ at = end;
530
+ continue;
531
+ }
532
+ const row =
533
+ run.split("\n").length === 2 ?
534
+ rowAt(enc, groups, "/", 0)
535
+ : { to: enc.controls.verseStanza };
536
+ if (!row) textError(text, at, "The encoding has no verse line row");
537
+ out.push(piece(text.slice(at, end), row.to, at, end, row));
538
+ at = end;
539
+ continue;
540
+ }
541
+ if (WHITE.test(first)) {
542
+ let end = at + first.length;
543
+ while (end < bound && WHITE.test(firstAt(text, end))) end += firstAt(text, end).length;
544
+ const following = end < text.length ? rowAt(enc, groups, text, end) : null;
545
+ const row =
546
+ /[\r\n]/.test(text.slice(at, end)) && following?.at === "line-start" ?
547
+ rowAt(enc, groups, "/", 0)
548
+ : enc.separatorRow;
549
+ if (!row) textError(text, at, "The encoding has no line row");
550
+ out.push(piece(text.slice(at, end), row.to, at, end, row));
551
+ at = end;
552
+ continue;
553
+ }
554
+ if (!enc.template) checkGroupCase(enc, groups, text, at, bound);
555
+ const row = enc.accepted.has(first) ? rowAt(enc, groups, text, at, bound) : null;
556
+ if (!row)
557
+ refused(
558
+ enc,
559
+ text,
560
+ at,
561
+ enc.accepted.has(first) && groups.some((group) => group.index.has(first)),
562
+ );
563
+ out.push(piece(row.from, row.to, at, at + row.from.length, row));
564
+ at += row.from.length;
565
+ }
566
+ return out;
567
+ }
568
+
569
+ function sourceProjection(enc, text, options) {
570
+ if (typeof text !== "string") throw new EncodingError("Text must be a string");
571
+ const start = options.start ?? 0,
572
+ end = options.end ?? text.length;
573
+ if (
574
+ !Number.isInteger(start) ||
575
+ !Number.isInteger(end) ||
576
+ start < 0 ||
577
+ end < start ||
578
+ end > text.length
579
+ )
580
+ throw new EncodingError("Encoding bounds must be ordered UTF-16 offsets within the input", {
581
+ key: "start",
582
+ });
583
+ if (!enc.template) return { normalized: text, sourceAt: null, begin: start, bound: end };
584
+ // Each canonical combining sequence has a source-boundary projection. Both
585
+ // diagnostics and pieces therefore refer to authored UTF-16, even after NFC.
586
+ let projection = templateSources.get(enc);
587
+ if (projection?.text !== text) {
588
+ let normalized = "";
589
+ const sourceAt = [0],
590
+ normalizedAt = new Map([[0, 0]]);
591
+ for (const match of text.matchAll(
592
+ /(?:[\u1100-\u1112][\u1161-\u1175][\u11A8-\u11C2]?|[\uAC00-\uD7A3][\u11A8-\u11C2]?|[^\p{M}])\p{M}*|\p{M}+/gu,
593
+ )) {
594
+ const source = match[0],
595
+ from = match.index,
596
+ value = source.normalize("NFC");
597
+ normalizedAt.set(from, normalized.length);
598
+ for (let i = 0; i < value.length; i++)
599
+ sourceAt[normalized.length + i] = source === value ? from + i : from;
600
+ normalized += value;
601
+ sourceAt[normalized.length] = from + source.length;
602
+ normalizedAt.set(from + source.length, normalized.length);
603
+ }
604
+ projection = { text, normalized, sourceAt, normalizedAt };
605
+ templateSources.set(enc, projection);
606
+ }
607
+ const { normalized, sourceAt, normalizedAt } = projection;
608
+ if (!normalizedAt.has(start) || !normalizedAt.has(end))
609
+ textError(
610
+ text,
611
+ !normalizedAt.has(start) ? start : end,
612
+ "Encoding bounds cannot split a combining sequence",
613
+ );
614
+ const begin = normalizedAt.get(start),
615
+ bound = normalizedAt.get(end);
616
+ return { normalized, sourceAt, begin, bound };
617
+ }
618
+ function cantoEnd(enc, text, at, bound) {
619
+ const source = "--- {.canto}";
620
+ return (
621
+ enc.controls.verseCanto &&
622
+ at + source.length <= bound &&
623
+ text.startsWith(source, at) &&
624
+ !text.slice(text.lastIndexOf("\n", at - 1) + 1, at).trim()
625
+ ) ?
626
+ at + source.length
627
+ : undefined;
628
+ }
629
+ function sourceDetails(enc, text, options) {
630
+ const groups = tiers(enc, options.font);
631
+ const projection = sourceProjection(enc, text, options);
632
+ const { normalized, sourceAt, begin, bound } = projection;
633
+ try {
634
+ for (let at = begin; at < bound;) {
635
+ const char = firstAt(normalized, at);
636
+ if (char === OPEN) {
637
+ const close = span(normalized.slice(0, bound), at, OPEN, CLOSE);
638
+ codePieces(enc, normalized, at + OPEN.length, close);
639
+ at = close + CLOSE.length;
640
+ continue;
641
+ }
642
+ if (char === CLOSE)
643
+ textError(normalized, at, "A closing literal marker has no opening marker");
644
+ const canto = cantoEnd(enc, normalized, at, bound);
645
+ if (canto !== undefined) {
646
+ at = canto;
647
+ continue;
648
+ }
649
+ const decomposed = char.normalize("NFD");
650
+ if (enc.template?.diaeresis && decomposed.includes("\u0308"))
651
+ textError(normalized, at, "Diaeresis is forbidden by the encoding");
652
+ if (enc.template?.grave && decomposed.includes("\u0300"))
653
+ textError(normalized, at, "Grave accents are forbidden by the encoding");
654
+ if (!WHITE.test(char) && !enc.accepted.has(char)) refused(enc, normalized, at, false);
655
+ if (char === "^" && !rowAt(enc, groups, normalized, at, bound))
656
+ textError(normalized, at, "Indentation is allowed only at line start");
657
+ at += char.length;
658
+ }
659
+ for (let at = begin; at < bound;) {
660
+ if (normalized[at] === OPEN) {
661
+ at = span(normalized.slice(0, bound), at, OPEN, CLOSE) + CLOSE.length;
662
+ continue;
663
+ }
664
+ const canto = cantoEnd(enc, normalized, at, bound);
665
+ if (canto !== undefined) {
666
+ at = canto;
667
+ continue;
668
+ }
669
+ checkGroupCase(enc, groups, normalized, at, bound);
670
+ const row = rowAt(enc, groups, normalized, at, bound);
671
+ at += row?.from.length ?? firstAt(normalized, at).length;
672
+ }
673
+ for (let at = begin; at < bound;) {
674
+ if (normalized[at] === OPEN) {
675
+ at = span(normalized.slice(0, bound), at, OPEN, CLOSE) + CLOSE.length;
676
+ continue;
677
+ }
678
+ const canto = cantoEnd(enc, normalized, at, bound);
679
+ if (canto !== undefined) {
680
+ at = canto;
681
+ continue;
682
+ }
683
+ const lookahead =
684
+ enc.template ?
685
+ pointSlice(normalized.slice(0, bound), at, enc.template.maxVowelGroup)
686
+ : "";
687
+ const lower = lookahead.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
688
+ const group = enc.template?.vowelGroups.find((value) => lower.startsWith(value));
689
+ if (group) {
690
+ let cursor = at;
691
+ for (let i = 0; i < [...group].length; i++) {
692
+ const vowel = firstAt(normalized, cursor);
693
+ if (i > 0 && enc.template.stress && vowel.normalize("NFD").includes("\u0301"))
694
+ textError(
695
+ normalized,
696
+ cursor,
697
+ "Stress belongs on the first vowel of an established vowel group",
698
+ );
699
+ cursor += vowel.length;
700
+ }
701
+ at = cursor;
702
+ } else at += firstAt(normalized, at).length;
703
+ }
704
+ } catch (error) {
705
+ if (error instanceof EncodingError && error.offset !== undefined && sourceAt)
706
+ throw new EncodingError(error.message, {
707
+ text,
708
+ offset: sourceAt[error.offset],
709
+ key: error.key,
710
+ });
711
+ throw error;
712
+ }
713
+ return projection;
714
+ }
715
+
716
+ /**
717
+ * Validate Roman spelling and literal syntax without requiring ordinary mapping coverage.
718
+ * Accepted words may instead have authoritative native writings in the language note.
719
+ * @param {ReturnType<typeof compileEncoding>} enc - Compiled encoding and its font codes.
720
+ * @param {string} text - Authored Roman source, normalized to NFC for template rules.
721
+ * @param {{font?:string,start?:number,end?:number}} [options] - Face and authored UTF-16 bounds.
722
+ * @returns {void} Throws EncodingError at the authored UTF-16 offset and code-point column.
723
+ */
724
+ export function validateSource(enc, text, options = {}) {
725
+ sourceDetails(enc, text, options);
726
+ }
727
+
728
+ /**
729
+ * Encode matched pieces while preserving offsets into the original source.
730
+ * @param {ReturnType<typeof compileEncoding>} enc - Compiled encoding note.
731
+ * @param {string} text - Authored Roman source.
732
+ * @param {{font?: string, verse?: boolean, start?: number, end?: number}} [options] - Source bounds and presentation.
733
+ */
734
+ export function encodePieces(enc, text, options = {}) {
735
+ if (!enc?.template) return encodeRawPieces(enc, text, options);
736
+ const { normalized, sourceAt, begin, bound } = sourceDetails(enc, text, options);
737
+ try {
738
+ return encodeRawPieces(enc, normalized, { ...options, start: begin, end: bound }).map(
739
+ (one) => ({
740
+ ...one,
741
+ from: text.slice(sourceAt[one.start], sourceAt[one.end]),
742
+ start: sourceAt[one.start],
743
+ end: sourceAt[one.end],
744
+ }),
745
+ );
746
+ } catch (error) {
747
+ if (error instanceof EncodingError && error.offset !== undefined)
748
+ throw new EncodingError(error.message, {
749
+ text,
750
+ offset: sourceAt[error.offset],
751
+ key: error.key,
752
+ });
753
+ throw error;
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Encode text using exactly the rows of its compiled encoding. Unknown input is refused.
759
+ * @param {ReturnType<typeof compileEncoding>} enc
760
+ * @param {string} text
761
+ * @param {{font?: string, verse?: boolean}} [options]
762
+ * @returns {string}
763
+ */
764
+ export function encodeText(enc, text, options = {}) {
765
+ return encodePieces(enc, text, options)
766
+ .map((piece) => piece.to)
767
+ .join("");
768
+ }
769
+ function decodeCodes(enc, text, start, end) {
770
+ let payload = "";
771
+ for (let at = start; at < end;) {
772
+ let char = firstAt(text, at);
773
+ if (char === ESCAPE_MARK) {
774
+ const escapedAt = at;
775
+ at += ESCAPE_MARK.length;
776
+ char = firstAt(text, at);
777
+ if (at >= end || ![OPEN_MARK, CLOSE_MARK, ESCAPE_MARK].includes(char))
778
+ textError(
779
+ text,
780
+ escapedAt,
781
+ "A literal escape must quote its open, close or escape control",
782
+ );
783
+ }
784
+ if (!scalar(char)) textError(text, at, "A literal span contains a non-scalar surrogate");
785
+ payload += char;
786
+ at += char.length;
787
+ }
788
+ text = payload;
789
+ let out = "";
790
+ let at = 0;
791
+ end = text.length;
792
+ while (at < end) {
793
+ const first = firstAt(text, at);
794
+ const row = (enc.codes.readings.get(first) ?? []).find(
795
+ (row) => at + row.to.length <= end && text.startsWith(row.to, at),
796
+ );
797
+ const code =
798
+ row?.from ?? `U+${first.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
799
+ if (wordy(chars(out).at(-1)) && wordy(chars(code)[0])) out += " ";
800
+ out += code;
801
+ at += row ? row.to.length : first.length;
802
+ }
803
+ return out;
804
+ }
805
+
806
+ /**
807
+ * Decode by longest output match, with source positions on any unidentified scalar.
808
+ * @param {ReturnType<typeof compileEncoding>} enc
809
+ * @param {string} text
810
+ * @param {{font?: string}} [options]
811
+ * @returns {string}
812
+ */
813
+ function decodeRawText(enc, text, { font } = {}) {
814
+ const readings = tiers(enc, font)[0].readings;
815
+ if (typeof text !== "string") throw new EncodingError("Text must be a string");
816
+ let out = "";
817
+ let at = 0;
818
+ while (at < text.length) {
819
+ if (text.startsWith(OPEN_MARK, at)) {
820
+ const end = span(text, at, OPEN_MARK, CLOSE_MARK);
821
+ out += OPEN + decodeCodes(enc, text, at + OPEN_MARK.length, end) + CLOSE;
822
+ at = end + CLOSE_MARK.length;
823
+ continue;
824
+ }
825
+ if (text.startsWith(CLOSE_MARK, at))
826
+ textError(text, at, "The closing literal mark has no opening mark");
827
+ const first = firstAt(text, at);
828
+ const row = (readings.get(first) ?? []).find((row) => text.startsWith(row.to, at));
829
+ if (!row) refused(enc, text, at, readings.has(first), true);
830
+ out += row.from;
831
+ at += row.to.length;
832
+ }
833
+ return out;
834
+ }
835
+
836
+ /**
837
+ * Decode a note's uniquely decodable output, including nonprefix glyph sequences.
838
+ * @param {ReturnType<typeof compileEncoding>} enc - Compiled encoding note.
839
+ * @param {string} text - Native text with optional literal spans.
840
+ * @param {{font?: string}} [options] - Selected encoding face.
841
+ * @returns {string} Roman source, with literals kept between their visible markers.
842
+ */
843
+ export function decodeText(enc, text, options = {}) {
844
+ if (!enc?.template) return decodeRawText(enc, text, options);
845
+ const readings = tiers(enc, options.font)[0].readings;
846
+ if (typeof text !== "string") throw new EncodingError("Text must be a string");
847
+ if (!templateReaders.has(enc)) {
848
+ const root = { children: new Map(), row: undefined };
849
+ for (const group of readings.values())
850
+ for (const row of group) {
851
+ let node = root;
852
+ for (const char of row.to) {
853
+ if (!node.children.has(char))
854
+ node.children.set(char, { children: new Map(), row: undefined });
855
+ node = node.children.get(char);
856
+ }
857
+ node.row = row;
858
+ }
859
+ templateReaders.set(enc, root);
860
+ }
861
+ const reader = templateReaders.get(enc);
862
+ // Right-to-left segmentation can look past a greedy dead end without
863
+ // recursion or copies of the remaining passage at each candidate.
864
+ const starts = [];
865
+ for (let at = 0; at < text.length; at += firstAt(text, at).length) starts.push(at);
866
+ const paths = new Map([[text.length, { from: "", next: text.length }]]);
867
+ let failedContext = text.length;
868
+ for (let i = starts.length - 1; i >= 0; i--) {
869
+ const at = starts[i];
870
+ if (text.startsWith(OPEN_MARK, at)) {
871
+ try {
872
+ const close = span(text, at, OPEN_MARK, CLOSE_MARK),
873
+ next = close + CLOSE_MARK.length;
874
+ if (paths.has(next))
875
+ paths.set(at, {
876
+ from: OPEN + decodeCodes(enc, text, at + OPEN_MARK.length, close) + CLOSE,
877
+ next,
878
+ });
879
+ } catch (error) {
880
+ if (!(error instanceof EncodingError)) throw error;
881
+ }
882
+ continue;
883
+ }
884
+ let cursor = reader,
885
+ next = at;
886
+ while (next < text.length) {
887
+ const char = firstAt(text, next);
888
+ cursor = cursor.children.get(char);
889
+ if (!cursor) break;
890
+ next += char.length;
891
+ const row = cursor.row;
892
+ if (!row || !paths.has(next)) continue;
893
+ if (row.before && !enc.vowels.has(firstAt(paths.get(next).from, 0))) {
894
+ failedContext = Math.min(failedContext, at);
895
+ continue;
896
+ }
897
+ paths.set(at, { from: row.from, next });
898
+ }
899
+ }
900
+ if (!paths.has(0)) {
901
+ // A greedy dead end is not necessarily the first unsupported scalar.
902
+ // Reach every complete prefix with the same trie before locating refusal.
903
+ const reachable = new Set([0]);
904
+ let furthest = 0;
905
+ for (const at of starts) {
906
+ if (!reachable.has(at)) continue;
907
+ if (text.startsWith(OPEN_MARK, at)) {
908
+ const close = span(text, at, OPEN_MARK, CLOSE_MARK);
909
+ decodeCodes(enc, text, at + OPEN_MARK.length, close);
910
+ const next = close + CLOSE_MARK.length;
911
+ reachable.add(next);
912
+ furthest = Math.max(furthest, next);
913
+ continue;
914
+ }
915
+ let cursor = reader,
916
+ next = at;
917
+ while (next < text.length) {
918
+ const char = firstAt(text, next);
919
+ cursor = cursor.children.get(char);
920
+ if (!cursor) break;
921
+ next += char.length;
922
+ if (cursor.row) {
923
+ reachable.add(next);
924
+ furthest = Math.max(furthest, next);
925
+ }
926
+ }
927
+ }
928
+ if (furthest < text.length) {
929
+ if (text.startsWith(CLOSE_MARK, furthest))
930
+ textError(text, furthest, "The closing literal mark has no opening mark");
931
+ refused(enc, text, furthest, reader.children.has(firstAt(text, furthest)), true);
932
+ }
933
+ textError(
934
+ text,
935
+ failedContext < text.length ? failedContext : 0,
936
+ "Native output does not satisfy the encoding's contextual mappings",
937
+ );
938
+ }
939
+ const out = [];
940
+ for (let at = 0; at < text.length;) {
941
+ const step = paths.get(at);
942
+ out.push(step.from);
943
+ at = step.next;
944
+ }
945
+ return out.join("");
946
+ }