@heroiclands/package-build 25.0.0 → 25.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -0
  3. package/bin/content-commands.mjs +30 -96
  4. package/bin/encoding-commands.mjs +146 -0
  5. package/bin/package-build.mjs +35 -24
  6. package/config.mjs +25 -0
  7. package/convert.mjs +182 -0
  8. package/docs/api.md +144 -6
  9. package/docs/authoring/links-and-markup.md +260 -481
  10. package/docs/commands.md +110 -119
  11. package/docs/configuration.md +31 -13
  12. package/docs/content-format.md +11 -4
  13. package/docs/diagnostics.md +43 -16
  14. package/docs/encoding-core.md +193 -0
  15. package/docs/guides/book.md +2 -2
  16. package/docs/guides/module-project.md +23 -1
  17. package/docs/guides/site.md +2 -2
  18. package/docs/guides/writing-in-invented-scripts.md +458 -575
  19. package/docs/reference/format-details.md +292 -404
  20. package/docs/reference/note-types.md +83 -63
  21. package/docs/source-preview.md +8 -0
  22. package/e2e.mjs +1 -1
  23. package/engine/base-compiler.mjs +27 -9
  24. package/engine/content-charset.mjs +13 -1
  25. package/engine/content-format.yaml +6 -2
  26. package/engine/content-index.mjs +6 -2
  27. package/engine/content-links.mjs +1 -0
  28. package/engine/content-scripts.mjs +614 -459
  29. package/engine/content-scroll.mjs +4 -4
  30. package/engine/content-spans.mjs +40 -9
  31. package/engine/content-verse.mjs +1 -1
  32. package/engine/converter-data.mjs +70 -0
  33. package/engine/dictionary-rows.mjs +2 -34
  34. package/engine/encoding-integrity.mjs +94 -0
  35. package/engine/encoding-language.mjs +506 -0
  36. package/engine/encoding-lexicon.mjs +701 -0
  37. package/engine/encoding-notes.mjs +186 -0
  38. package/engine/encoding-standard.mjs +42 -0
  39. package/engine/encoding-template.mjs +370 -0
  40. package/engine/encoding-terms.mjs +178 -0
  41. package/engine/encoding.mjs +952 -0
  42. package/engine/extension-attributes.mjs +2 -0
  43. package/engine/helpers.mjs +1 -1
  44. package/engine/index.mjs +7 -0
  45. package/engine/language-terms.mjs +17 -16
  46. package/engine/{logographic.mjs → language-writings.mjs} +29 -38
  47. package/engine/markup-validation.mjs +1 -1
  48. package/engine/note-vocabulary.mjs +4 -158
  49. package/engine/pdf-render.mjs +31 -3
  50. package/engine/roman-characters.mjs +135 -16
  51. package/engine/roman-run.mjs +149 -0
  52. package/engine/roman-verse.mjs +95 -0
  53. package/engine/run-rows.mjs +29 -31
  54. package/engine/run-words.mjs +2 -2
  55. package/engine/script-emphasis-terms.mjs +3 -0
  56. package/engine/script-emphasis.mjs +3 -10
  57. package/engine/script-font-delivery.mjs +9 -12
  58. package/engine/script-fonts.mjs +2 -1
  59. package/engine/site-build.mjs +14 -5
  60. package/engine/site-preview.mjs +12 -2
  61. package/engine/sql-tables.mjs +36 -31
  62. package/engine/verse-metre.mjs +1 -1
  63. package/engine/word-forms.mjs +1 -1
  64. package/engine/word-inflections.mjs +48 -0
  65. package/engine/word-rows.mjs +1 -1
  66. package/engine/word-terms.mjs +16 -17
  67. package/package.json +11 -5
  68. package/types/config.d.mts +5 -0
  69. package/types/convert.d.mts +44 -0
  70. package/types/e2e.d.mts +1 -1
  71. package/types/engine/base-compiler.d.mts +4 -2
  72. package/types/engine/content-index.d.mts +4 -1
  73. package/types/engine/content-scripts.d.mts +103 -42
  74. package/types/engine/converter-data.d.mts +11 -0
  75. package/types/engine/dictionary-rows.d.mts +1 -25
  76. package/types/engine/encoding-integrity.d.mts +14 -0
  77. package/types/engine/encoding-language.d.mts +36 -0
  78. package/types/engine/encoding-lexicon.d.mts +259 -0
  79. package/types/engine/encoding-notes.d.mts +39 -0
  80. package/types/engine/encoding-standard.d.mts +11 -0
  81. package/types/engine/encoding-template.d.mts +44 -0
  82. package/types/engine/encoding-terms.d.mts +143 -0
  83. package/types/engine/encoding.d.mts +155 -0
  84. package/types/engine/helpers.d.mts +1 -1
  85. package/types/engine/index.d.mts +4 -0
  86. package/types/engine/language-terms.d.mts +1 -1
  87. package/types/engine/{logographic.d.mts → language-writings.d.mts} +6 -6
  88. package/types/engine/markup-validation.d.mts +1 -1
  89. package/types/engine/pdf-render.d.mts +1 -1
  90. package/types/engine/roman-characters.d.mts +2 -2
  91. package/types/engine/roman-run.d.mts +101 -0
  92. package/types/engine/roman-verse.d.mts +23 -0
  93. package/types/engine/run-rows.d.mts +13 -20
  94. package/types/engine/script-emphasis-terms.d.mts +2 -0
  95. package/types/engine/script-emphasis.d.mts +1 -1
  96. package/types/engine/script-font-delivery.d.mts +2 -3
  97. package/types/engine/script-fonts.d.mts +5 -0
  98. package/types/engine/site-preview.d.mts +1 -1
  99. package/types/engine/word-inflections.d.mts +25 -0
  100. package/engine/map-lexicon.mjs +0 -1045
  101. package/engine/text-maps.mjs +0 -654
  102. package/engine/written-in.mjs +0 -204
  103. package/types/engine/map-lexicon.d.mts +0 -330
  104. package/types/engine/text-maps.d.mts +0 -189
  105. package/types/engine/written-in.d.mts +0 -76
@@ -0,0 +1,701 @@
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-aware language records, validation and plain JSON conversion inputs. */
15
+ import fs from "node:fs";
16
+ import { encodingAddress } from "./encoding-notes.mjs";
17
+ import { parseAddress, renderAddress } from "./address.mjs";
18
+ import { positionOfFrontmatterPath, positionOfYamlPath } from "./diagnostics.mjs";
19
+ import { isNoteRecord } from "./index-records.mjs";
20
+ import { nearest } from "./near-miss.mjs";
21
+ import { isLanguageRecord, lineageChain, noteAddressOf } from "./word-rows.mjs";
22
+ import { derivedForms, foldWord } from "./word-forms.mjs";
23
+ import { WRITTEN_ENDINGS, RESERVED_TONGUE } from "./language-terms.mjs";
24
+ import { LANGUAGE_SUBTYPE, glossText } from "./word-terms.mjs";
25
+ import { writingsOf } from "./language-writings.mjs";
26
+ import { encodeText, validateSource, EncodingError } from "./encoding.mjs";
27
+ import { encodeWithLanguage } from "./encoding-language.mjs";
28
+ /** The configuration file a language is declared in. */
29
+ const CONFIG = "package-build.config.yaml";
30
+
31
+ /** Whether a value is a plain map. */
32
+ const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
33
+
34
+ /** A list value, or an empty one. */
35
+ const listOf = (value) => (Array.isArray(value) ? value : []);
36
+
37
+ /** `U+XXXX` for a code point. */
38
+ const codeName = (point) => `U+${point.toString(16).toUpperCase().padStart(4, "0")}`;
39
+
40
+ /** Names in backticks, joined as a list is said: `a`, `b` and `c`. */
41
+ function said(names) {
42
+ const quoted = names.map((name) => `\`${name}\``);
43
+ return quoted.length < 2 ?
44
+ quoted.join("")
45
+ : `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
46
+ }
47
+
48
+ /**
49
+ * One native form, read.
50
+ *
51
+ * @typedef {object} NativeForm
52
+ * @property {string} enc - The encoding it is written for.
53
+ * @property {string|undefined} font - The one face it is for.
54
+ * @property {string} text - The word in the script.
55
+ */
56
+
57
+ /**
58
+ * One word of a lexicon.
59
+ *
60
+ * @typedef {object} LexiconWord
61
+ * @property {string} form - The word as cited.
62
+ * @property {string|null} class - Its class.
63
+ * @property {string|null} gloss - Its senses, joined by `; `.
64
+ * @property {readonly NativeForm[]} natives - Its native forms.
65
+ */
66
+
67
+ /**
68
+ * A language's lexicon.
69
+ *
70
+ * @typedef {object} Lexicon
71
+ * @property {string} language - The language's configured name.
72
+ * @property {readonly LexiconWord[]} words - Its words, in `data.words` order.
73
+ * @property {ReadonlyMap<string, readonly NativeForm[]>} byForm - The native
74
+ * forms by the word's form, NFC; the first word of a form keeps it.
75
+ * @property {ReadonlyArray<{form: string, from: LexiconWord}>} derived - The
76
+ * forms the language's rules derive from its words, folded, as the mapped-run
77
+ * word check accepts them (see {@link module:engine/word-forms.derivedForms}).
78
+ * @property {Record<string, any>} data - The language note's `data`, which a
79
+ * encoding writes words from.
80
+ */
81
+
82
+ /**
83
+ * A word's native forms as written, keeping those that read.
84
+ *
85
+ * @param {unknown} value - A word's `native`.
86
+ * @returns {NativeForm[]} The forms.
87
+ */
88
+ function readNatives(value) {
89
+ return listOf(value)
90
+ .filter(
91
+ (entry) =>
92
+ isMap(entry) && typeof entry.enc === "string" && typeof entry.text === "string",
93
+ )
94
+ .map((entry) => ({
95
+ enc: entry.enc,
96
+ font: typeof entry.font === "string" ? entry.font : undefined,
97
+ text: entry.text,
98
+ }));
99
+ }
100
+
101
+ /**
102
+ * A language note's words as a lexicon.
103
+ *
104
+ * @param {string} language - The language's configured name.
105
+ * @param {Record<string, any>} data - The note's `data`.
106
+ * @returns {Lexicon} The lexicon.
107
+ */
108
+ function lexiconOf(language, data) {
109
+ const words = [];
110
+ const byForm = new Map();
111
+ const bySource = new Map();
112
+ for (const word of listOf(data?.words)) {
113
+ if (!isMap(word) || typeof word.form !== "string") continue;
114
+ const natives = readNatives(word.native);
115
+ const read = Object.freeze({
116
+ form: word.form,
117
+ class: typeof word.class === "string" ? word.class : null,
118
+ gloss: glossText(word.gloss),
119
+ natives,
120
+ });
121
+ words.push(read);
122
+ bySource.set(word, read);
123
+ const key = word.form.normalize("NFC");
124
+ if (!byForm.has(key)) byForm.set(key, natives);
125
+ }
126
+ const derived = derivedForms(data).map(({ form, word }) =>
127
+ Object.freeze({ form, from: bySource.get(word) }),
128
+ );
129
+ return Object.freeze({
130
+ language,
131
+ words: Object.freeze(words),
132
+ byForm,
133
+ derived: Object.freeze(derived),
134
+ data: isMap(data) ? data : {},
135
+ });
136
+ }
137
+
138
+ /**
139
+ * The canonical Address `languages.<name>.note` names, or `undefined` where
140
+ * the value is no Address.
141
+ *
142
+ * @param {unknown} note - The configured value.
143
+ * @param {string|undefined} contentPackage - The package it is read in.
144
+ * @param {Iterable<string>} types - The types a qualified value may name.
145
+ * @returns {string|undefined} The Address.
146
+ */
147
+ function noteKey(note, contentPackage, types) {
148
+ if (!contentPackage) return undefined;
149
+ const tuple = parseAddress(
150
+ note,
151
+ { package: contentPackage, system: "note", type: "skill", types: new Set(types) },
152
+ { declared: true },
153
+ );
154
+ return "reason" in tuple ? undefined : renderAddress(tuple);
155
+ }
156
+
157
+ /** Resolve native encoding references in their author's package without changing authored data. */
158
+ function resolvedNativeData(value, contentPackage) {
159
+ if (Array.isArray(value)) return value.map((item) => resolvedNativeData(item, contentPackage));
160
+ if (!isMap(value)) return value;
161
+ return Object.fromEntries(
162
+ Object.entries(value).map(([key, item]) => [
163
+ key,
164
+ key === "native" && Array.isArray(item) ?
165
+ item.map((entry) =>
166
+ isMap(entry) && typeof entry.enc === "string" ?
167
+ { ...entry, enc: encodingAddress(entry.enc, contentPackage) ?? entry.enc }
168
+ : entry,
169
+ )
170
+ : resolvedNativeData(item, contentPackage),
171
+ ]),
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Each record by the Address a reference to it is written as.
177
+ *
178
+ * @param {Iterable<Record<string, any>>} records - Index records.
179
+ * @param {string|undefined} contentPackage - The package a record with no
180
+ * `package` belongs to.
181
+ * @returns {Map<string, Record<string, any>>} The records.
182
+ */
183
+ function recordsByKey(records, contentPackage) {
184
+ const byKey = new Map();
185
+ for (const record of records ?? []) {
186
+ if (!record || !isNoteRecord(record) || !record.type || !record.shortcode) continue;
187
+ const pkg = record.package ?? contentPackage;
188
+ if (!pkg) continue;
189
+ const key = renderAddress({
190
+ package: String(pkg),
191
+ system: "note",
192
+ type: String(record.type),
193
+ shortcode: String(record.shortcode),
194
+ });
195
+ if (!byKey.has(key)) byKey.set(key, record);
196
+ }
197
+ return byKey;
198
+ }
199
+
200
+ /** Whether a record is a language note's. */
201
+ const isLanguage = (record) =>
202
+ record?.type === "skill" && String(record.subType ?? "").toLowerCase() === LANGUAGE_SUBTYPE;
203
+
204
+ /**
205
+ * The language note each configured language names.
206
+ *
207
+ * @param {Readonly<Record<string, {tag: string, note?: string}>>} languages -
208
+ * The configured languages.
209
+ * @param {string|undefined} contentPackage - The package.
210
+ * @param {Iterable<Record<string, any>>} records - Index records.
211
+ * @returns {Map<string, {key: string|undefined, record: Record<string, any>|undefined}>}
212
+ * Each language stating a `note`, with what it resolves to.
213
+ */
214
+ function languageNotes(languages, contentPackage, records) {
215
+ const list = [...(records ?? [])];
216
+ const byKey = recordsByKey(list, contentPackage);
217
+ const types = new Set(list.map((record) => record?.type).filter(Boolean));
218
+ types.add("skill");
219
+ const out = new Map();
220
+ for (const [name, spec] of Object.entries(languages ?? {})) {
221
+ if (spec?.note === undefined) continue;
222
+ const key = noteKey(spec.note, contentPackage, types);
223
+ const record = key ? byKey.get(key) : undefined;
224
+ out.set(name, { key, record: isLanguage(record) ? record : undefined });
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * Each configured language's lexicon, read from the language note its `note`
231
+ * names and its family chain. The nearest exact NFC form supplies the whole
232
+ * word; grammar, registers and other settings remain the daughter's own.
233
+ * A language with no `note`, or one naming no language note, has none.
234
+ *
235
+ * @param {Iterable<Record<string, any>>|undefined} records - Index records.
236
+ * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
237
+ * The resolved configuration.
238
+ * @returns {Map<string, Lexicon>} The lexicons by language name.
239
+ */
240
+ export function lexiconsFromRecords(records, config = {}) {
241
+ const list = [...(records ?? [])];
242
+ const byKey = recordsByKey(list, config.contentPackage);
243
+ const types = new Set(["skill", ...list.map((record) => record?.type).filter(Boolean)]);
244
+ const lineage = {
245
+ has: (key) => isLanguage(byKey.get(key)),
246
+ parentOf: (key) => {
247
+ const record = byKey.get(key);
248
+ return noteKey(record.data?.family, record.package ?? config.contentPackage, types);
249
+ },
250
+ };
251
+ const lexicons = new Map();
252
+ for (const [name, { key, record }] of languageNotes(
253
+ config.languages,
254
+ config.contentPackage,
255
+ list,
256
+ )) {
257
+ if (!record) continue;
258
+ const chain = lineageChain(key, lineage);
259
+ let data = resolvedNativeData(record.data, record.package ?? config.contentPackage);
260
+ if (chain.length > 1) {
261
+ const words = new Map();
262
+ for (const ancestor of chain)
263
+ for (const word of listOf(
264
+ resolvedNativeData(
265
+ byKey.get(ancestor).data,
266
+ byKey.get(ancestor).package ?? config.contentPackage,
267
+ )?.words,
268
+ )) {
269
+ if (!isMap(word) || typeof word.form !== "string") continue;
270
+ // The nearest complete definition wins before word matching
271
+ // folds case or splits compounds. Only words are inherited.
272
+ const form = word.form.normalize("NFC");
273
+ if (!words.has(form)) words.set(form, word);
274
+ }
275
+ data = { ...data, words: [...words.values()] };
276
+ }
277
+ lexicons.set(name, lexiconOf(name, data));
278
+ }
279
+ return lexicons;
280
+ }
281
+
282
+ /**
283
+ * The language note each configured language's `note` names, by language
284
+ * name. A language with no `note`, or one naming no language note, is absent.
285
+ *
286
+ * @param {Iterable<Record<string, any>>|undefined} records - Index records.
287
+ * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
288
+ * The resolved configuration.
289
+ * @returns {Map<string, Record<string, any>>} The records.
290
+ */
291
+ export function languageRecordsByName(records, { languages, contentPackage } = {}) {
292
+ const out = new Map();
293
+ for (const [name, { record }] of languageNotes(languages, contentPackage, records))
294
+ if (record) out.set(name, record);
295
+ return out;
296
+ }
297
+
298
+ /**
299
+ * Each `languages.<name>.note` that names no language note, at its key.
300
+ *
301
+ * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string, configFile?: string, configText?: string}} config -
302
+ * The resolved configuration, and where it was read from.
303
+ * @param {Iterable<Record<string, any>>} records - Index records.
304
+ * @returns {object[]} The findings.
305
+ */
306
+ export function languageNoteFindings(config, records) {
307
+ const { languages, contentPackage, configFile, configText } = config ?? {};
308
+ let text = configText;
309
+ if (text === undefined && configFile) {
310
+ try {
311
+ text = fs.readFileSync(configFile, "utf8");
312
+ } catch {
313
+ text = undefined;
314
+ }
315
+ }
316
+ const findings = [];
317
+ for (const [name, { record }] of languageNotes(languages, contentPackage, records)) {
318
+ if (record) {
319
+ if (typeof record.data?.roman !== "string")
320
+ findings.push({
321
+ file: configFile ?? CONFIG,
322
+ ...(text ?
323
+ positionOfYamlPath(text, ["languages", name, "note"], { key: true })
324
+ : {}),
325
+ severity: "error",
326
+ message: `the language note named by \`languages.${name}.note\` must declare \`data.roman\``,
327
+ });
328
+ continue;
329
+ }
330
+ findings.push({
331
+ file: configFile ?? CONFIG,
332
+ ...(text ? positionOfYamlPath(text, ["languages", name, "note"], { key: true }) : {}),
333
+ severity: "error",
334
+ message:
335
+ `\`languages.${name}.note\` is \`${String(languages[name].note)}\`, which names no ` +
336
+ `language note in ${contentPackage}; it names a skill note whose subType is language`,
337
+ });
338
+ }
339
+ return findings;
340
+ }
341
+
342
+ /** Every column of the `natives` relation, in order. */
343
+ export const NATIVE_COLUMNS = Object.freeze([
344
+ "language",
345
+ "word",
346
+ "form",
347
+ "native",
348
+ "enc",
349
+ "font",
350
+ "text",
351
+ ]);
352
+
353
+ /**
354
+ * One row per native form of a language note's word, for the `natives`
355
+ * relation: a text is a value of a row, never a struct key, so forms that
356
+ * differ only in case stay apart.
357
+ *
358
+ * @param {Iterable<Record<string, any>>} records - Index records.
359
+ * @returns {Array<Record<string, string|number|null>>} The rows; `word` is the
360
+ * word's 0-based place in `data.words`, `native` the form's in its `native`.
361
+ */
362
+ export function nativeRows(records) {
363
+ const rows = [];
364
+ for (const record of records) {
365
+ if (!isLanguageRecord(record)) continue;
366
+ const language = noteAddressOf(record);
367
+ if (!language) continue;
368
+ listOf(record.data?.words).forEach((word, position) => {
369
+ if (!isMap(word) || typeof word.form !== "string") return;
370
+ listOf(word.native).forEach((entry, at) => {
371
+ if (
372
+ !isMap(entry) ||
373
+ typeof entry.enc !== "string" ||
374
+ typeof entry.text !== "string"
375
+ )
376
+ return;
377
+ rows.push({
378
+ language,
379
+ word: position,
380
+ form: word.form,
381
+ native: at,
382
+ enc: entry.enc,
383
+ font: typeof entry.font === "string" ? entry.font : null,
384
+ text: entry.text,
385
+ });
386
+ });
387
+ });
388
+ }
389
+ return rows;
390
+ }
391
+
392
+ /** Project only conversion-readable language data to a JSON-safe object. */
393
+ export function serializeLexicon(lexicon) {
394
+ if (!lexicon) return undefined;
395
+ const select = (value, keys) =>
396
+ Object.fromEntries(
397
+ keys
398
+ .filter((key) => value?.[key] !== undefined)
399
+ .map((key) => [key, structuredClone(value[key])]),
400
+ );
401
+ const source = lexicon.data ?? {};
402
+ const data = {
403
+ words: listOf(source.words).map((word) =>
404
+ select(word, ["form", "class", "gloss", "variants", "plural", "built", "native"]),
405
+ ),
406
+ affixes: listOf(source.affixes).map((affix) =>
407
+ select(affix, ["form", "front", "kind", "classes", "attachesTo", "replaces", "native"]),
408
+ ),
409
+ register: listOf(source.register).map((row) => select(row, ["name", "tongue"])),
410
+ ...(source.grammar ?
411
+ {
412
+ grammar: select(source.grammar, [
413
+ "plural",
414
+ "verbs",
415
+ "native",
416
+ "agreement",
417
+ "participles",
418
+ ]),
419
+ }
420
+ : {}),
421
+ };
422
+ return { language: lexicon.language, data };
423
+ }
424
+
425
+ /**
426
+ * Resolve encoding, face and optional language input without loading files.
427
+ *
428
+ * @param {import("./content-scripts.mjs").RunContext} ctx - Installed encodings,
429
+ * document aliases and lexicons prepared by the caller.
430
+ * @param {object} [options] - Explicit selections; an omitted encoding is the Roman default.
431
+ * @param {string} [options.enc] - Encoding document Address.
432
+ * @param {string} [options.font] - The encoding document's font.
433
+ * @param {string} [options.lang] - Configured language whose lexicon to use.
434
+ */
435
+ export function encodingFor(ctx, { enc, font, lang } = {}) {
436
+ const name = enc ?? "default";
437
+ const encoding =
438
+ ctx.encodings?.get(name) ?? ctx.encodings?.get(encodingAddress(name, ctx.contentPackage));
439
+ if (
440
+ !encoding ||
441
+ (encoding.language !== undefined &&
442
+ lang !== undefined &&
443
+ lang !== encoding.language &&
444
+ (ctx.languages?.[encoding.language]?.tag ?? "").toLowerCase() !==
445
+ (ctx.languages?.[lang]?.tag ?? lang).toLowerCase())
446
+ )
447
+ return undefined;
448
+ const face = font ?? encoding.font ?? encoding.fonts[0];
449
+ return {
450
+ encoding,
451
+ font: face,
452
+ lexicon: serializeLexicon(ctx.lexicons?.get(lang) ?? ctx.lexicons?.get(encoding.language)),
453
+ };
454
+ }
455
+
456
+ /** Why a face is incompatible with the selected encoding. */
457
+ export function fontProblem(encoding, font) {
458
+ return encoding.fonts.includes(font) ?
459
+ undefined
460
+ : `\`${font}\` is not a font of the encoding \`${encoding.name}\`; it serves ${said(encoding.fonts)}`;
461
+ }
462
+
463
+ /** List authored and optionally derived forms with their selected native writings. */
464
+ export function lexiconEntries(ctx, { lang, enc, font, derived = false }) {
465
+ const selected = encodingFor(ctx, { lang, enc, font });
466
+ if (!selected)
467
+ return {
468
+ about: "enc",
469
+ problem: `No encoding document is selected, or its language conflicts; name an encoding with --enc`,
470
+ };
471
+ const problem = fontProblem(selected.encoding, selected.font);
472
+ if (problem) return { about: "font", problem };
473
+ const lexicon = ctx.lexicons?.get(lang);
474
+ if (!lexicon) return { about: "lang", problem: `\`${lang}\` has no lexicon` };
475
+ const writing = writingsOf(lexicon.data, selected.encoding.name, selected.font);
476
+ const listed = [
477
+ ...lexicon.words.map((word, index) => ({ word, form: word.form, index })),
478
+ ...(derived ?
479
+ lexicon.derived.map((one) => ({ word: one.from, form: one.form, from: one.from.form }))
480
+ : []),
481
+ ];
482
+ return {
483
+ entries: listed.map(({ word, form, index, from }) => {
484
+ const own = index === undefined ? writing.word(form) : writing.entry(index);
485
+ const result =
486
+ own && "text" in own ?
487
+ { text: own.text, findings: [] }
488
+ : encodeWithLanguage(selected.encoding, form, {
489
+ font: selected.font,
490
+ lexicon: selected.lexicon,
491
+ });
492
+ return {
493
+ form,
494
+ native: result.text,
495
+ class: word.class,
496
+ gloss: word.gloss,
497
+ source:
498
+ own && "text" in own ? own.source
499
+ : result.findings.length ? "none"
500
+ : "rows",
501
+ exception: own && "text" in own,
502
+ ...("ambiguous" in own ? { ambiguous: true } : {}),
503
+ ...(from === undefined ? {} : { from }),
504
+ };
505
+ }),
506
+ };
507
+ }
508
+
509
+ /** Validate word, affix and grammar-ending native entries against installed encodings. */
510
+ export function checkLexiconNatives(note, ctx) {
511
+ if (!isLanguage(note.fm) || ctx.encodingFindings?.length) return [];
512
+ const findings = [],
513
+ data = note.fm?.data ?? {},
514
+ encodings = ctx.encodings ?? new Map();
515
+ const report = (keys, message) =>
516
+ findings.push({
517
+ file: note.file,
518
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", ...keys]),
519
+ severity: "error",
520
+ message,
521
+ });
522
+ function check(entries, keys, form) {
523
+ const slots = new Set();
524
+ listOf(entries).forEach((entry, i) => {
525
+ if (!isMap(entry) || typeof entry.enc !== "string" || typeof entry.text !== "string")
526
+ return;
527
+ const encoding =
528
+ encodings.get(entry.enc) ??
529
+ encodings.get(encodingAddress(entry.enc, ctx.contentPackage));
530
+ if (!encoding) {
531
+ report(
532
+ [...keys, i, "enc"],
533
+ `\`${entry.enc}\` does not resolve to an encoding document`,
534
+ );
535
+ return;
536
+ }
537
+ if (entry.font !== undefined && !encoding.fonts.includes(entry.font)) {
538
+ report([...keys, i, "font"], fontProblem(encoding, entry.font));
539
+ return;
540
+ }
541
+ if (
542
+ keys[0] === "grammar" &&
543
+ entry.ending !== "plural" &&
544
+ WRITTEN_ENDINGS.includes(entry.ending) &&
545
+ !isMap(data.grammar?.verbs?.persons)
546
+ )
547
+ report(
548
+ [...keys, i, "ending"],
549
+ `The ending \`${entry.ending}\` needs data.grammar.verbs.persons`,
550
+ );
551
+ const slot = `${entry.ending ?? ""}\0${encoding.name}\0${entry.font ?? ""}`;
552
+ if (slots.has(slot))
553
+ report([...keys, i], `A second native writing names the same encoding and face`);
554
+ slots.add(slot);
555
+ const fonts =
556
+ entry.font ?
557
+ [entry.font]
558
+ : encoding.fonts.filter(
559
+ (font) =>
560
+ !listOf(entries).some(
561
+ (other) =>
562
+ other.enc === entry.enc &&
563
+ other.font === font &&
564
+ other.ending === entry.ending,
565
+ ),
566
+ );
567
+ if (typeof form === "string") {
568
+ const same =
569
+ fonts.length > 0 &&
570
+ fonts.every((font) => {
571
+ try {
572
+ return encodeText(encoding, form, { font }) === entry.text;
573
+ } catch {
574
+ return false;
575
+ }
576
+ });
577
+ if (same)
578
+ report(
579
+ [...keys, i, "text"],
580
+ `Native writing states what the rows already give for \`${form}\``,
581
+ );
582
+ }
583
+ for (const font of fonts) {
584
+ const face = ctx.faces?.get(font);
585
+ if (!face) continue;
586
+ const seen = new Set();
587
+ for (const char of entry.text) {
588
+ const point = char.codePointAt(0);
589
+ if (
590
+ /\s/u.test(char) ||
591
+ (point >= 0xe0100 && point <= 0xe01ef) ||
592
+ face.draws.has(point) ||
593
+ seen.has(point)
594
+ )
595
+ continue;
596
+ seen.add(point);
597
+ report(
598
+ [...keys, i, "text"],
599
+ `Native writing holds ${codeName(point)}, which the font \`${font}\` does not draw`,
600
+ );
601
+ }
602
+ }
603
+ });
604
+ }
605
+ listOf(data.words).forEach((word, i) =>
606
+ check(word?.native, ["words", i, "native"], word?.form),
607
+ );
608
+ listOf(data.affixes).forEach((affix, i) => check(affix?.native, ["affixes", i, "native"]));
609
+ check(data.grammar?.native, ["grammar", "native"]);
610
+ return findings;
611
+ }
612
+
613
+ /** Validate every authored word and name against its language's encoding documents.
614
+ * @param {{fm:Record<string,any>,file:string,raw?:string}} note - The source language note.
615
+ * @param {import("./content-scripts.mjs").RunContext} ctx - Compiled documents and language identities.
616
+ * @returns {object[]} Positioned orthography findings, independent of lexical native overrides.
617
+ */
618
+ export function checkEncodingLexicon(note, ctx) {
619
+ if (!isLanguage(note.fm) || ctx.encodingFindings?.length) return [];
620
+ const aliases = new Set(
621
+ [...(ctx.languageRecords ?? [])]
622
+ .filter(
623
+ ([, record]) =>
624
+ record.shortcode === note.fm.shortcode &&
625
+ (record.package ?? ctx.contentPackage) === ctx.contentPackage,
626
+ )
627
+ .map(([alias]) => alias),
628
+ );
629
+ const encodings = [
630
+ ...new Map(
631
+ [...(ctx.encodings?.values() ?? [])].map((encoding) => [encoding.name, encoding]),
632
+ ).values(),
633
+ ].filter((encoding) => aliases.has(encoding.language));
634
+ if (!encodings.length) return [];
635
+ const forms = [],
636
+ data = note.fm.data ?? {};
637
+ const add = (form, keys, element = false) => {
638
+ if (typeof form === "string" && form) forms.push({ form, keys, element });
639
+ };
640
+ listOf(data.words).forEach((word, i) => {
641
+ add(word?.form, ["words", i, "form"]);
642
+ listOf(word?.variants).forEach((form, j) => add(form, ["words", i, "variants", j]));
643
+ });
644
+ const names = (value, keys) => {
645
+ if (Array.isArray(value))
646
+ value.forEach((item, i) =>
647
+ typeof item === "string" ? add(item, [...keys, i]) : names(item, [...keys, i]),
648
+ );
649
+ else if (isMap(value))
650
+ for (const [key, item] of Object.entries(value))
651
+ if (key !== "pattern" && key !== "metadata") names(item, [...keys, key]);
652
+ };
653
+ names(data.names, ["names"]);
654
+ listOf(data.elements).forEach((element, i) => {
655
+ add(element?.form, ["elements", i, "form"], true);
656
+ listOf(element?.variants).forEach((form, j) =>
657
+ add(form, ["elements", i, "variants", j], true),
658
+ );
659
+ listOf(element?.examples).forEach((form, j) => add(form, ["elements", i, "examples", j]));
660
+ });
661
+ listOf(data.register).forEach((name, i) => {
662
+ if (name?.tongue !== RESERVED_TONGUE) add(name?.name, ["register", i, "name"]);
663
+ });
664
+ const findings = [],
665
+ seen = new Set();
666
+ for (const encoding of encodings)
667
+ for (const { form, keys, element } of forms) {
668
+ // Edge hyphens on bound elements mark joining positions, not sounds.
669
+ const written = element ? form.replace(/^-|-$/gu, "") : form;
670
+ try {
671
+ validateSource(encoding, written, { font: encoding.font });
672
+ const converted = encodeWithLanguage(encoding, written, {
673
+ font: encoding.font,
674
+ lexicon: serializeLexicon(ctx.lexicons?.get(encoding.language)),
675
+ });
676
+ if (converted.findings.length)
677
+ throw new EncodingError(converted.findings[0].message);
678
+ } catch (error) {
679
+ if (!(error instanceof EncodingError)) throw error;
680
+ const identity = JSON.stringify([encoding.name, form, error.message]);
681
+ if (seen.has(identity)) continue;
682
+ seen.add(identity);
683
+ const position = positionOfFrontmatterPath(note.raw ?? "", ["data", ...keys]);
684
+ if (position.line !== undefined && position.column !== undefined)
685
+ position.column =
686
+ [
687
+ ...((note.raw ?? "").split("\n")[position.line - 1] ?? "").slice(
688
+ 0,
689
+ position.column - 1,
690
+ ),
691
+ ].length + 1;
692
+ findings.push({
693
+ file: note.file,
694
+ ...position,
695
+ severity: "error",
696
+ message: `\`${form}\` is not valid Roman spelling for encoding \`${encoding.name}\`: ${error.message}`,
697
+ });
698
+ }
699
+ }
700
+ return findings;
701
+ }