@heroiclands/package-build 25.0.0 → 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 (105) hide show
  1. package/CHANGELOG.md +14 -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 +137 -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 +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 +450 -574
  19. package/docs/reference/format-details.md +289 -404
  20. package/docs/reference/note-types.md +80 -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 +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 +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 +43 -0
  82. package/types/engine/encoding-terms.d.mts +105 -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,506 @@
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
+ /** Language-aware conversion over plain JSON lexicons, usable in browsers and Node. */
15
+ import {
16
+ encodePieces,
17
+ encodeText,
18
+ decodeText,
19
+ EncodingError,
20
+ validateSource,
21
+ } from "./encoding.mjs";
22
+ import { writingsOf } from "./language-writings.mjs";
23
+ import { derivedForms, foldWord } from "./word-forms.mjs";
24
+ import { WORD } from "./word-pattern.mjs";
25
+
26
+ const OPEN = "\u{E0112}",
27
+ CLOSE = "\u{E0113}",
28
+ ESCAPE = "\u{E0115}";
29
+ const MARKS = /[\u{E0100}-\u{E01EF}]/u;
30
+ const stripMarks = (text) => text.replace(/[\u{E0100}-\u{E01EF}]/gu, "");
31
+ const list = (value) => (Array.isArray(value) ? value : []);
32
+ const letter = /\p{L}/u;
33
+ const cp = (text, at) => String.fromCodePoint(text.codePointAt(at));
34
+ const lexical = (text) => /^[\p{L}\p{M}\p{N}’'–-]+$/u.test(text);
35
+ function finding(text, offset, message) {
36
+ return {
37
+ severity: "error",
38
+ offset,
39
+ line: text.slice(0, offset).split("\n").length,
40
+ column: [...text.slice(text.lastIndexOf("\n", offset - 1) + 1, offset)].length + 1,
41
+ message,
42
+ };
43
+ }
44
+ function optionsFor(enc, options) {
45
+ return { ...options, font: options.font ?? enc.fonts[0] };
46
+ }
47
+ function initialCapital(word) {
48
+ const first = cp(word, 0);
49
+ return first.toUpperCase() + word.slice(first.length);
50
+ }
51
+ function capitalWriting(enc, word, native, font) {
52
+ const first = (source) => {
53
+ try {
54
+ return encodePieces(enc, source, { font })[0];
55
+ } catch (error) {
56
+ if (!(error instanceof EncodingError) || !(error.offset > 0)) return undefined;
57
+ try {
58
+ return encodePieces(enc, source, { font, end: error.offset })[0];
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ };
64
+ const lower = first(word.toLowerCase()),
65
+ upper = first(word);
66
+ return (
67
+ lower && upper && lower.from.length === upper.from.length && native.startsWith(lower.to)
68
+ ) ?
69
+ upper.to + native.slice(lower.to.length)
70
+ : undefined;
71
+ }
72
+ function entryForms(lexicon) {
73
+ const data = lexicon?.data ?? {};
74
+ const derived = new Map();
75
+ for (const entry of derivedForms(data)) {
76
+ if (!derived.has(entry.word)) derived.set(entry.word, []);
77
+ derived.get(entry.word).push(entry);
78
+ }
79
+ // Authored word order decides recovery ties. Each citation precedes its
80
+ // own variants and derived forms, before the next word's citation.
81
+ const words = list(data.words).filter((word) => typeof word?.form === "string");
82
+ return [
83
+ ...words.flatMap((word) => {
84
+ const seen = new Set();
85
+ const seenComponents = new Set();
86
+ const forms = [];
87
+ for (const form of [
88
+ word.form,
89
+ ...list(word.variants),
90
+ ...(derived.get(word) ?? []).map((entry) => entry.form),
91
+ ]) {
92
+ if (typeof form !== "string" || !form || seen.has(form)) continue;
93
+ seen.add(form);
94
+ forms.push({ form, word });
95
+ const parts = [...form.matchAll(WORD)].map(([part]) => part);
96
+ if (parts.length > 1)
97
+ for (const part of parts) {
98
+ if (seenComponents.has(part)) continue;
99
+ seenComponents.add(part);
100
+ // Components establish recovery and known-word membership,
101
+ // not ownership of their expression's native spelling.
102
+ forms.push({ form: part, component: true });
103
+ }
104
+ }
105
+ return forms;
106
+ }),
107
+ ...list(data.register)
108
+ .filter((row) => typeof row?.name === "string" && row.tongue !== "translation")
109
+ .flatMap((row) => {
110
+ const canonical = foldWord(row.name);
111
+ const forms = [...new Set([row.name, canonical])].map((form) => ({
112
+ form,
113
+ name: true,
114
+ canonical,
115
+ }));
116
+ if ([...row.name.matchAll(WORD)].length > 1)
117
+ for (const [part] of row.name.matchAll(WORD))
118
+ for (const form of new Set([part, foldWord(part)]))
119
+ forms.push({ form, name: false, namePart: true });
120
+ return forms;
121
+ }),
122
+ ];
123
+ }
124
+
125
+ function literalEnd(text, at) {
126
+ let cursor = at + OPEN.length;
127
+ while (cursor < text.length) {
128
+ if (text.startsWith(ESCAPE, cursor)) {
129
+ cursor +=
130
+ ESCAPE.length +
131
+ (cursor + ESCAPE.length < text.length ?
132
+ cp(text, cursor + ESCAPE.length).length
133
+ : 0);
134
+ continue;
135
+ }
136
+ if (text.startsWith(CLOSE, cursor)) return cursor + CLOSE.length;
137
+ cursor += cp(text, cursor).length;
138
+ }
139
+ return text.length;
140
+ }
141
+
142
+ /**
143
+ * Encode using the lexicon's own and composed writings before the encoding rows.
144
+ * @param {ReturnType<import("./encoding.mjs").compileEncoding>} enc
145
+ * @param {string} text
146
+ * @param {{font?:string,verse?:boolean,lexicon?:{language:string,data:Record<string,any>}}} [options]
147
+ * @returns {{text:string,pieces:object[],findings:object[]}}
148
+ */
149
+ export function encodeWithLanguage(enc, text, options = {}) {
150
+ options = optionsFor(enc, options);
151
+ const findings = [],
152
+ pieces = [];
153
+ if (enc.template) {
154
+ try {
155
+ validateSource(enc, text, { font: options.font });
156
+ } catch (error) {
157
+ if (!(error instanceof EncodingError)) throw error;
158
+ return {
159
+ text: "",
160
+ pieces,
161
+ findings: [finding(text, error.offset ?? 0, error.message)],
162
+ };
163
+ }
164
+ }
165
+ const writing =
166
+ options.lexicon ? writingsOf(options.lexicon.data, enc.name, options.font) : undefined;
167
+ const phrases =
168
+ options.lexicon ?
169
+ entryForms(options.lexicon)
170
+ .map((entry) => entry.form)
171
+ .filter((form) => [...form.matchAll(WORD)].length > 1)
172
+ .map((form) => ({
173
+ form,
174
+ spaces: form.match(/[\p{Zs}\u0009-\u000D]+/gu) ?? [],
175
+ pattern: new RegExp(
176
+ form
177
+ .split(/[\p{Zs}\u0009-\u000D]+/u)
178
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
179
+ .join("[\\p{Zs}\\u0009-\\u000D]+"),
180
+ "iuy",
181
+ ),
182
+ }))
183
+ : [];
184
+ let at = 0;
185
+ const appendRows = (from, to) => {
186
+ let cursor = from;
187
+ while (cursor < to) {
188
+ try {
189
+ pieces.push(
190
+ ...encodePieces(enc, text, {
191
+ font: options.font,
192
+ verse: options.verse,
193
+ start: cursor,
194
+ end: to,
195
+ }),
196
+ );
197
+ return;
198
+ } catch (error) {
199
+ if (!(error instanceof EncodingError)) throw error;
200
+ const offset = error.offset ?? cursor;
201
+ findings.push(finding(text, offset, error.message));
202
+ if (offset > cursor) {
203
+ try {
204
+ pieces.push(
205
+ ...encodePieces(enc, text, {
206
+ font: options.font,
207
+ verse: options.verse,
208
+ start: cursor,
209
+ end: offset,
210
+ }),
211
+ );
212
+ } catch {
213
+ /* An invalid literal is refused as one span. */
214
+ }
215
+ }
216
+ const open = text.lastIndexOf("⟦", offset),
217
+ close = text.lastIndexOf("⟧", offset);
218
+ if (open > close) {
219
+ const end = text.indexOf("⟧", offset);
220
+ cursor = end < 0 ? to : end + 1;
221
+ } else cursor = offset + (offset < text.length ? cp(text, offset).length : 1);
222
+ }
223
+ }
224
+ };
225
+ for (const match of text.matchAll(WORD)) {
226
+ const start = match.index;
227
+ if (start < at) continue;
228
+ // Literal contents belong to the encoding's codes, not to a language lexicon.
229
+ const open = text.lastIndexOf("⟦", start),
230
+ close = text.lastIndexOf("⟧", start);
231
+ if (open > close) continue;
232
+ // The authored longest phrase wins before individual word overrides.
233
+ // Match only complete words, and leave literal payloads to the codes.
234
+ let phrase;
235
+ for (const candidate of phrases) {
236
+ candidate.pattern.lastIndex = start;
237
+ const found = candidate.pattern.exec(text)?.[0];
238
+ if (
239
+ !found ||
240
+ found.includes("⟦") ||
241
+ found.includes("⟧") ||
242
+ /[\p{L}\p{M}\p{N}’'–-]/u.test(text[start + found.length] ?? "")
243
+ )
244
+ continue;
245
+ let space = 0;
246
+ const lookup = found.replace(
247
+ /[\p{Zs}\u0009-\u000D]+/gu,
248
+ () => candidate.spaces[space++],
249
+ );
250
+ const own = writing.word(lookup);
251
+ if (!("text" in own) && !("ambiguous" in own)) continue;
252
+ if (!phrase || found.length > phrase.text.length) phrase = { text: found, lookup, own };
253
+ }
254
+ if (phrase) match[0] = phrase.text;
255
+ const lookup = phrase?.lookup ?? match[0];
256
+ const own = phrase?.own ?? writing?.word(lookup);
257
+ if (own && "ambiguous" in own) {
258
+ appendRows(at, start);
259
+ findings.push(
260
+ finding(
261
+ text,
262
+ start,
263
+ `\`${match[0]}\` has ambiguous writings in the \`${options.lexicon.language}\` lexicon: ${own.ambiguous.glosses.join("; ")}`,
264
+ ),
265
+ );
266
+ at = start + match[0].length;
267
+ } else if (own && "text" in own) {
268
+ appendRows(at, start);
269
+ let native = own.text;
270
+ const exact = list(options.lexicon.data.words).some(
271
+ (entry) => entry.form === lookup || list(entry.variants).includes(lookup),
272
+ );
273
+ if (!exact && match[0] !== match[0].toLowerCase()) {
274
+ const capital =
275
+ match[0] === initialCapital(match[0].toLowerCase()) ?
276
+ capitalWriting(enc, match[0], native, options.font)
277
+ : undefined;
278
+ if (capital === undefined) {
279
+ findings.push(
280
+ finding(
281
+ text,
282
+ start,
283
+ `The encoding cannot capitalize the native writing of \`${match[0].toLowerCase()}\`; author \`${match[0]}\` with its own native entry`,
284
+ ),
285
+ );
286
+ at = start + match[0].length;
287
+ continue;
288
+ }
289
+ native = capital;
290
+ }
291
+ pieces.push({
292
+ from: match[0],
293
+ to: native,
294
+ start,
295
+ end: start + match[0].length,
296
+ row: null,
297
+ native: true,
298
+ source: own.source,
299
+ });
300
+ at = start + match[0].length;
301
+ } else if (letter.test(match[0]) && ![...enc.accepted].some((char) => letter.test(char))) {
302
+ appendRows(at, start);
303
+ findings.push(
304
+ finding(
305
+ text,
306
+ start,
307
+ `\`${match[0]}\` has no writing in the \`${options.lexicon?.language ?? "unspecified language"}\` lexicon for \`${enc.name}\`; write it from the lexicon or as ⟦codage⟧`,
308
+ ),
309
+ );
310
+ at = start + match[0].length;
311
+ }
312
+ }
313
+ appendRows(at, text.length);
314
+ return { text: findings.length ? "" : pieces.map((p) => p.to).join(""), pieces, findings };
315
+ }
316
+
317
+ /**
318
+ * Decode with explicit writings first and recover unmarked words from the lexicon.
319
+ * @param {ReturnType<import("./encoding.mjs").compileEncoding>} enc
320
+ * @param {string} text
321
+ * @param {{font?:string,lexicon?:{language:string,data:Record<string,any>}}} [options]
322
+ * @returns {{text:string,findings:object[]}}
323
+ */
324
+ export function decodeWithLanguage(enc, text, options = {}) {
325
+ options = optionsFor(enc, options);
326
+ if (!options.lexicon) {
327
+ try {
328
+ return { text: decodeText(enc, text, options), findings: [] };
329
+ } catch (error) {
330
+ if (!(error instanceof EncodingError)) throw error;
331
+ return { text: "", findings: [finding(text, error.offset ?? 0, error.message)] };
332
+ }
333
+ }
334
+ const findings = [],
335
+ forms = entryForms(options.lexicon),
336
+ writing = writingsOf(options.lexicon.data, enc.name, options.font);
337
+ const own = [];
338
+ const skeletons = new Map();
339
+ const knownForms = new Set(
340
+ forms.filter((entry) => !entry.name).map((entry) => foldWord(entry.form)),
341
+ );
342
+ for (const entry of forms) {
343
+ const native = entry.name || entry.component ? undefined : writing.word(entry.form);
344
+ // Keep authored senses in order; entry-specific native texts can share a spelling.
345
+ // Derived forms instead use their composed stem and ending writing above.
346
+ const authored =
347
+ entry.form === entry.word?.form || list(entry.word?.variants).includes(entry.form);
348
+ const explicit =
349
+ authored ?
350
+ list(entry.word?.native).filter(
351
+ (n) => n.enc === enc.name && (!n.font || n.font === options.font),
352
+ )
353
+ : [];
354
+ const chosen =
355
+ explicit.find((n) => n.font === options.font) ?? explicit.find((n) => !n.font);
356
+ if (chosen || (native && "text" in native)) {
357
+ const target = chosen?.text ?? native.text;
358
+ own.push({ text: target, form: entry.form, name: entry.name });
359
+ if (entry.form === entry.form.toLowerCase()) {
360
+ const title = initialCapital(entry.form),
361
+ capital = capitalWriting(enc, title, target, options.font);
362
+ if (capital !== undefined && capital !== target)
363
+ own.push({ text: capital, form: title });
364
+ }
365
+ }
366
+ if (entry.name) {
367
+ const encoded = encodeWithLanguage(enc, entry.form, options);
368
+ if (!encoded.findings.length) {
369
+ const target = stripMarks(encoded.text);
370
+ if (target)
371
+ own.push({ text: target, form: entry.canonical, name: true, recovery: true });
372
+ }
373
+ }
374
+ try {
375
+ const skeleton = decodeText(
376
+ enc,
377
+ stripMarks(encodeText(enc, entry.form, options)),
378
+ options,
379
+ );
380
+ if (skeleton && !skeletons.has(skeleton)) skeletons.set(skeleton, entry);
381
+ } catch (error) {
382
+ if (!(error instanceof EncodingError)) throw error;
383
+ }
384
+ }
385
+ own.sort((a, b) => [...b.text].length - [...a.text].length);
386
+ const nativeReadings = new Map();
387
+ for (const entry of own) {
388
+ if (!entry.text) continue;
389
+ const first = cp(entry.text, 0);
390
+ if (!nativeReadings.has(first)) nativeReadings.set(first, []);
391
+ nativeReadings.get(first).push(entry);
392
+ }
393
+ const reader = enc.faces.get(options.font)?.readings ?? enc.shared.readings;
394
+ const noLetters = ![...enc.accepted].some((char) => letter.test(char));
395
+ let out = "",
396
+ at = 0,
397
+ named = false;
398
+ function plain(raw, offset) {
399
+ try {
400
+ return decodeText(enc, raw, options);
401
+ } catch (error) {
402
+ if (!(error instanceof EncodingError)) throw error;
403
+ findings.push(finding(text, offset + (error.offset ?? 0), error.message));
404
+ return "";
405
+ }
406
+ }
407
+ const rowAt = (offset) =>
408
+ (reader.get(cp(text, offset)) ?? []).find((row) => text.startsWith(row.to, offset));
409
+ const wordEnd = (from) => {
410
+ let cursor = from;
411
+ while (cursor < text.length && !text.startsWith(OPEN, cursor)) {
412
+ const row = rowAt(cursor);
413
+ if (row && !lexical(row.from)) break;
414
+ cursor += row?.to.length ?? cp(text, cursor).length;
415
+ }
416
+ return cursor;
417
+ };
418
+ const nativeAt = (offset) =>
419
+ (nativeReadings.get(cp(text, offset)) ?? []).find((entry) => {
420
+ if (!entry.text || !text.startsWith(entry.text, offset)) return false;
421
+ if (noLetters) return true;
422
+ // A shorter native spelling must not consume a prefix of a complete
423
+ // alphabetic word. A native writing may itself cross spaces or marks.
424
+ const end = offset + entry.text.length;
425
+ return end === wordEnd(end);
426
+ });
427
+ let previousWord = false;
428
+ const appendWord = (roman) => {
429
+ if (noLetters && previousWord) out += " ";
430
+ out += roman;
431
+ previousWord = true;
432
+ };
433
+ while (at < text.length) {
434
+ if (text.startsWith(OPEN, at)) {
435
+ const end = literalEnd(text, at);
436
+ out += plain(text.slice(at, end), at);
437
+ at = end;
438
+ previousWord = false;
439
+ continue;
440
+ }
441
+ const native = nativeAt(at);
442
+ if (native) {
443
+ appendWord(native.name && !named ? `{${native.form}}` : native.form);
444
+ at += native.text.length;
445
+ continue;
446
+ }
447
+ const row = rowAt(at);
448
+ if (row && !lexical(row.from)) {
449
+ out += row.from;
450
+ named =
451
+ row.from === "{" ? true
452
+ : row.from === "}" ? false
453
+ : named;
454
+ at += row.to.length;
455
+ previousWord = false;
456
+ continue;
457
+ }
458
+ const start = at;
459
+ if (noLetters) {
460
+ // An unknown stretch ends before the next known writing, so a
461
+ // missing word cannot swallow the rest of a sign passage.
462
+ at += row?.to.length ?? cp(text, at).length;
463
+ while (at < text.length && !text.startsWith(OPEN, at) && !nativeAt(at)) {
464
+ const following = rowAt(at);
465
+ if (following && !lexical(following.from)) break;
466
+ at += following?.to.length ?? cp(text, at).length;
467
+ }
468
+ const raw = text.slice(start, at);
469
+ const roman = `⟦${[...raw].map((char) => `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(" ")}⟧`;
470
+ appendWord(decodeText(enc, encodeText(enc, roman, options), options));
471
+ findings.push(
472
+ finding(
473
+ text,
474
+ start,
475
+ `These signs are not a word of the \`${options.lexicon.language}\` lexicon`,
476
+ ),
477
+ );
478
+ continue;
479
+ }
480
+ at = wordEnd(at);
481
+ const raw = text.slice(start, at);
482
+ const decoded = plain(raw, start);
483
+ if (MARKS.test(raw)) {
484
+ out += decoded;
485
+ continue;
486
+ }
487
+ const recovered = skeletons.get(decoded);
488
+ if (recovered)
489
+ out +=
490
+ recovered.name && !named ? `{${recovered.canonical ?? recovered.form}}`
491
+ : recovered.name ? (recovered.canonical ?? recovered.form)
492
+ : recovered.form;
493
+ else if (knownForms.has(foldWord(decoded))) out += decoded;
494
+ else {
495
+ out += decoded;
496
+ findings.push(
497
+ finding(
498
+ text,
499
+ start,
500
+ `\`${decoded}\` is not in the \`${options.lexicon.language}\` lexicon`,
501
+ ),
502
+ );
503
+ }
504
+ }
505
+ return { text: out, findings };
506
+ }