@heroiclands/package-build 23.12.5 → 23.12.7

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 (95) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/assets/styles/content-base.css +25 -0
  3. package/bin/content-commands.mjs +264 -3
  4. package/bin/package-build.mjs +25 -0
  5. package/content-config.mjs +98 -0
  6. package/docs/api.md +18 -14
  7. package/docs/authoring/dates-and-calendars.md +29 -1
  8. package/docs/authoring/links-and-markup.md +442 -50
  9. package/docs/commands.md +127 -0
  10. package/docs/configuration.md +90 -3
  11. package/docs/content-format.md +3 -1
  12. package/docs/guides/book.md +11 -0
  13. package/docs/guides/module-project.md +17 -1
  14. package/docs/guides/site.md +19 -0
  15. package/docs/reference/format-details.md +434 -23
  16. package/docs/reference/note-types.md +45 -21
  17. package/engine/base-compiler.mjs +2 -0
  18. package/engine/bundles.mjs +7 -2
  19. package/engine/compile-corpus.mjs +3 -1
  20. package/engine/content-charset.mjs +21 -2
  21. package/engine/content-format.yaml +1 -2
  22. package/engine/content-index.mjs +33 -2
  23. package/engine/content-links.mjs +3 -0
  24. package/engine/content-poetry.mjs +22 -4
  25. package/engine/content-scripts.mjs +1219 -0
  26. package/engine/content-session.mjs +2 -0
  27. package/engine/data-keys.mjs +5 -1
  28. package/engine/dictionary-layout.mjs +18 -12
  29. package/engine/dictionary-rows.mjs +11 -2
  30. package/engine/event-rows.mjs +63 -18
  31. package/engine/event-views.mjs +28 -15
  32. package/engine/helpers.mjs +8 -2
  33. package/engine/index-records.mjs +2 -0
  34. package/engine/infobox.mjs +4 -8
  35. package/engine/journals.mjs +14 -3
  36. package/engine/language-data.mjs +137 -1
  37. package/engine/language-tag.mjs +103 -0
  38. package/engine/language-terms.mjs +16 -4
  39. package/engine/map-lexicon.mjs +762 -0
  40. package/engine/markdown-expressions.mjs +72 -17
  41. package/engine/markup-validation.mjs +17 -3
  42. package/engine/names.mjs +1 -1
  43. package/engine/note-calendars.mjs +314 -0
  44. package/engine/note-vocabulary.mjs +124 -16
  45. package/engine/pdf-build.mjs +28 -10
  46. package/engine/pdf-render.mjs +116 -11
  47. package/engine/reckoning-markers.mjs +63 -3
  48. package/engine/run-rows.mjs +119 -0
  49. package/engine/run-words.mjs +266 -0
  50. package/engine/scenes.mjs +1 -0
  51. package/engine/script-font-delivery.mjs +321 -0
  52. package/engine/script-fonts.mjs +172 -0
  53. package/engine/site-build.mjs +10 -3
  54. package/engine/site-config.mjs +5 -0
  55. package/engine/site-root.mjs +19 -5
  56. package/engine/sql-tables.mjs +120 -4
  57. package/engine/text-maps.mjs +607 -0
  58. package/engine/verb-pattern.mjs +99 -0
  59. package/engine/word-pattern.mjs +27 -0
  60. package/engine/word-rows.mjs +8 -2
  61. package/engine/word-terms.mjs +44 -0
  62. package/hugo-theme/layouts/_default/baseof.html +3 -0
  63. package/hugo-theme/static/css/style.css +10 -0
  64. package/manifest.mjs +9 -1
  65. package/package.json +1 -1
  66. package/types/content-config.d.mts +38 -0
  67. package/types/engine/compile-corpus.d.mts +2 -1
  68. package/types/engine/content-index.d.mts +17 -3
  69. package/types/engine/content-poetry.d.mts +7 -0
  70. package/types/engine/content-scripts.d.mts +226 -0
  71. package/types/engine/data-keys.d.mts +2 -2
  72. package/types/engine/dictionary-rows.d.mts +15 -0
  73. package/types/engine/event-rows.d.mts +16 -1
  74. package/types/engine/event-views.d.mts +2 -1
  75. package/types/engine/helpers.d.mts +5 -1
  76. package/types/engine/journals.d.mts +7 -2
  77. package/types/engine/language-data.d.mts +2 -0
  78. package/types/engine/language-tag.d.mts +8 -0
  79. package/types/engine/language-terms.d.mts +2 -1
  80. package/types/engine/map-lexicon.d.mts +274 -0
  81. package/types/engine/markup-validation.d.mts +7 -2
  82. package/types/engine/note-calendars.d.mts +60 -0
  83. package/types/engine/pdf-build.d.mts +26 -3
  84. package/types/engine/pdf-render.d.mts +17 -2
  85. package/types/engine/reckoning-markers.d.mts +32 -1
  86. package/types/engine/run-rows.d.mts +89 -0
  87. package/types/engine/run-words.d.mts +86 -0
  88. package/types/engine/script-font-delivery.d.mts +134 -0
  89. package/types/engine/script-fonts.d.mts +168 -0
  90. package/types/engine/site-build.d.mts +2 -1
  91. package/types/engine/site-root.d.mts +11 -3
  92. package/types/engine/text-maps.d.mts +175 -0
  93. package/types/engine/verb-pattern.d.mts +65 -0
  94. package/types/engine/word-pattern.d.mts +13 -0
  95. package/types/engine/word-terms.d.mts +6 -0
@@ -0,0 +1,762 @@
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
+ /**
15
+ * **A map reads its language's dictionary first.** The sound rules belong to
16
+ * the script: a mapping note's rows spell every language written in it. The
17
+ * exceptions belong to the language: a word of a language note's
18
+ * `data.words` states a `native` form for a map only where the rows would
19
+ * spell it differently.
20
+ *
21
+ * ```yaml
22
+ * words:
23
+ * - form: zaron
24
+ * class: n
25
+ * field: Law
26
+ * gloss: an oath
27
+ * native:
28
+ * - {map: semaron, text: "…"}
29
+ * - {map: semaron, font: semaron-carved, text: "…"}
30
+ * ```
31
+ *
32
+ * - **`languages.<name>.note`** in the configuration names the language note
33
+ * whose words are the language's lexicon. A run marked `lang=` with that
34
+ * name and a `map=` reads it; a language with no `note`, a bare BCP 47 tag
35
+ * and a run with no `lang=` are spelled by the rows alone.
36
+ * - **A native form with a `font`** applies in that face alone, and wins there
37
+ * over one with none.
38
+ * - **Derived forms take the rows**: a plural or a verb ending of an exception
39
+ * word is spelled by the rows, and only the word as cited is looked up.
40
+ *
41
+ * {@link mappingFor} is the one path from a map, a face and a language to the
42
+ * function that rewrites text: the build's runs, `package-build transliterate`
43
+ * and `package-build lexicon` all go through it.
44
+ *
45
+ * @module
46
+ */
47
+
48
+ import fs from "node:fs";
49
+
50
+ import { parseAddress, renderAddress } from "./address.mjs";
51
+ import { positionOfFrontmatterPath, positionOfYamlPath } from "./diagnostics.mjs";
52
+ import { isNoteRecord } from "./index-records.mjs";
53
+ import { nearest } from "./near-miss.mjs";
54
+ import { FONTS_PACKAGE } from "./script-fonts.mjs";
55
+ import { GENERIC_FAMILIES, applyMap } from "./text-maps.mjs";
56
+ import { isLanguageRecord, noteAddressOf } from "./word-rows.mjs";
57
+ import { LANGUAGE_SUBTYPE, glossText } from "./word-terms.mjs";
58
+
59
+ /** The configuration file a language is declared in. */
60
+ const CONFIG = "package-build.config.yaml";
61
+
62
+ /** Whether a value is a plain map. */
63
+ const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
64
+
65
+ /** A list value, or an empty one. */
66
+ const listOf = (value) => (Array.isArray(value) ? value : []);
67
+
68
+ /** `U+XXXX` for a code point. */
69
+ const codeName = (point) => `U+${point.toString(16).toUpperCase().padStart(4, "0")}`;
70
+
71
+ /** Names in backticks, joined as a list is said: `a`, `b` and `c`. */
72
+ function said(names) {
73
+ const quoted = names.map((name) => `\`${name}\``);
74
+ return quoted.length < 2 ?
75
+ quoted.join("")
76
+ : `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
77
+ }
78
+
79
+ /**
80
+ * One native form, read.
81
+ *
82
+ * @typedef {object} NativeForm
83
+ * @property {string} map - The map it is written for.
84
+ * @property {string|undefined} font - The one face it is for.
85
+ * @property {string} text - The word in the script.
86
+ */
87
+
88
+ /**
89
+ * One word of a lexicon.
90
+ *
91
+ * @typedef {object} LexiconWord
92
+ * @property {string} form - The word as cited.
93
+ * @property {string|null} class - Its class.
94
+ * @property {string|null} gloss - Its senses, joined by `; `.
95
+ * @property {readonly NativeForm[]} natives - Its native forms.
96
+ */
97
+
98
+ /**
99
+ * A language's lexicon.
100
+ *
101
+ * @typedef {object} Lexicon
102
+ * @property {string} language - The language's configured name.
103
+ * @property {readonly LexiconWord[]} words - Its words, in `data.words` order.
104
+ * @property {ReadonlyMap<string, readonly NativeForm[]>} byForm - The native
105
+ * forms by the word's form, NFC; the first word of a form keeps it.
106
+ */
107
+
108
+ /**
109
+ * A word's native forms as written, keeping those that read.
110
+ *
111
+ * @param {unknown} value - A word's `native`.
112
+ * @returns {NativeForm[]} The forms.
113
+ */
114
+ function readNatives(value) {
115
+ return listOf(value)
116
+ .filter(
117
+ (entry) =>
118
+ isMap(entry) && typeof entry.map === "string" && typeof entry.text === "string",
119
+ )
120
+ .map((entry) => ({
121
+ map: entry.map,
122
+ font: typeof entry.font === "string" ? entry.font : undefined,
123
+ text: entry.text,
124
+ }));
125
+ }
126
+
127
+ /**
128
+ * A language note's words as a lexicon.
129
+ *
130
+ * @param {string} language - The language's configured name.
131
+ * @param {Record<string, any>} data - The note's `data`.
132
+ * @returns {Lexicon} The lexicon.
133
+ */
134
+ function lexiconOf(language, data) {
135
+ const words = [];
136
+ const byForm = new Map();
137
+ for (const word of listOf(data?.words)) {
138
+ if (!isMap(word) || typeof word.form !== "string") continue;
139
+ const natives = readNatives(word.native);
140
+ words.push(
141
+ Object.freeze({
142
+ form: word.form,
143
+ class: typeof word.class === "string" ? word.class : null,
144
+ gloss: glossText(word.gloss),
145
+ natives,
146
+ }),
147
+ );
148
+ const key = word.form.normalize("NFC");
149
+ if (!byForm.has(key)) byForm.set(key, natives);
150
+ }
151
+ return Object.freeze({ language, words: Object.freeze(words), byForm });
152
+ }
153
+
154
+ /**
155
+ * The canonical Address `languages.<name>.note` names, or `undefined` where
156
+ * the value is no Address.
157
+ *
158
+ * @param {unknown} note - The configured value.
159
+ * @param {string|undefined} contentPackage - The package it is read in.
160
+ * @param {Iterable<string>} types - The types a qualified value may name.
161
+ * @returns {string|undefined} The Address.
162
+ */
163
+ function noteKey(note, contentPackage, types) {
164
+ if (typeof note !== "string" || !contentPackage) return undefined;
165
+ const tuple = parseAddress(
166
+ note,
167
+ { package: contentPackage, system: "note", type: "skill", types: new Set(types) },
168
+ { declared: true },
169
+ );
170
+ return "reason" in tuple ? undefined : renderAddress(tuple);
171
+ }
172
+
173
+ /**
174
+ * Each record by the Address a reference to it is written as.
175
+ *
176
+ * @param {Iterable<Record<string, any>>} records - Index records.
177
+ * @param {string|undefined} contentPackage - The package a record with no
178
+ * `package` belongs to.
179
+ * @returns {Map<string, Record<string, any>>} The records.
180
+ */
181
+ function recordsByKey(records, contentPackage) {
182
+ const byKey = new Map();
183
+ for (const record of records ?? []) {
184
+ if (!record || !isNoteRecord(record) || !record.type || !record.shortcode) continue;
185
+ const pkg = record.package ?? contentPackage;
186
+ if (!pkg) continue;
187
+ const key = renderAddress({
188
+ package: String(pkg),
189
+ system: "note",
190
+ type: String(record.type),
191
+ shortcode: String(record.shortcode),
192
+ });
193
+ if (!byKey.has(key)) byKey.set(key, record);
194
+ }
195
+ return byKey;
196
+ }
197
+
198
+ /** Whether a record is a language note's. */
199
+ const isLanguage = (record) =>
200
+ record?.type === "skill" && String(record.subType ?? "").toLowerCase() === LANGUAGE_SUBTYPE;
201
+
202
+ /**
203
+ * The language note each configured language names.
204
+ *
205
+ * @param {Readonly<Record<string, {tag: string, note?: string}>>} languages -
206
+ * The configured languages.
207
+ * @param {string|undefined} contentPackage - The package.
208
+ * @param {Iterable<Record<string, any>>} records - Index records.
209
+ * @returns {Map<string, {key: string|undefined, record: Record<string, any>|undefined}>}
210
+ * Each language stating a `note`, with what it resolves to.
211
+ */
212
+ function languageNotes(languages, contentPackage, records) {
213
+ const list = [...(records ?? [])];
214
+ const byKey = recordsByKey(list, contentPackage);
215
+ const types = new Set(list.map((record) => record?.type).filter(Boolean));
216
+ types.add("skill");
217
+ const out = new Map();
218
+ for (const [name, spec] of Object.entries(languages ?? {})) {
219
+ if (spec?.note === undefined) continue;
220
+ const key = noteKey(spec.note, contentPackage, types);
221
+ const record = key ? byKey.get(key) : undefined;
222
+ out.set(name, { key, record: isLanguage(record) ? record : undefined });
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * Each configured language's lexicon, read from the language note its `note`
229
+ * names. A language with no `note`, or one naming no language note, has none.
230
+ *
231
+ * @param {Iterable<Record<string, any>>|undefined} records - Index records.
232
+ * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
233
+ * The resolved configuration.
234
+ * @returns {Map<string, Lexicon>} The lexicons by language name.
235
+ */
236
+ export function lexiconsFromRecords(records, config = {}) {
237
+ const lexicons = new Map();
238
+ for (const [name, record] of languageRecordsByName(records, config))
239
+ lexicons.set(name, lexiconOf(name, record.data));
240
+ return lexicons;
241
+ }
242
+
243
+ /**
244
+ * The language note each configured language's `note` names, by language
245
+ * name. A language with no `note`, or one naming no language note, is absent.
246
+ *
247
+ * @param {Iterable<Record<string, any>>|undefined} records - Index records.
248
+ * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
249
+ * The resolved configuration.
250
+ * @returns {Map<string, Record<string, any>>} The records.
251
+ */
252
+ export function languageRecordsByName(records, { languages, contentPackage } = {}) {
253
+ const out = new Map();
254
+ for (const [name, { record }] of languageNotes(languages, contentPackage, records))
255
+ if (record) out.set(name, record);
256
+ return out;
257
+ }
258
+
259
+ /**
260
+ * Each `languages.<name>.note` that names no language note, at its key.
261
+ *
262
+ * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string, configFile?: string, configText?: string}} config -
263
+ * The resolved configuration, and where it was read from.
264
+ * @param {Iterable<Record<string, any>>} records - Index records.
265
+ * @returns {object[]} The findings.
266
+ */
267
+ export function languageNoteFindings(config, records) {
268
+ const { languages, contentPackage, configFile, configText } = config ?? {};
269
+ let text = configText;
270
+ if (text === undefined && configFile) {
271
+ try {
272
+ text = fs.readFileSync(configFile, "utf8");
273
+ } catch {
274
+ text = undefined;
275
+ }
276
+ }
277
+ const findings = [];
278
+ for (const [name, { record }] of languageNotes(languages, contentPackage, records)) {
279
+ if (record) continue;
280
+ findings.push({
281
+ file: configFile ?? CONFIG,
282
+ ...(text ? positionOfYamlPath(text, ["languages", name, "note"], { key: true }) : {}),
283
+ severity: "error",
284
+ message:
285
+ `\`languages.${name}.note\` is \`${String(languages[name].note)}\`, which names no ` +
286
+ `language note in ${contentPackage}; it names a skill note whose subType is language`,
287
+ });
288
+ }
289
+ return findings;
290
+ }
291
+
292
+ /**
293
+ * A lookup of a word's native form under one map in one face: the form stated
294
+ * for the face, else the one stated for no face.
295
+ *
296
+ * @param {Lexicon|undefined} lexicon - The lexicon.
297
+ * @param {string} map - The map's name.
298
+ * @param {string|undefined} font - The face.
299
+ * @returns {((word: string) => string|undefined)|undefined} The lookup, or
300
+ * `undefined` with no lexicon.
301
+ */
302
+ export function nativeLookup(lexicon, map, font) {
303
+ if (!lexicon) return undefined;
304
+ return (word) => {
305
+ const natives = lexicon.byForm.get(word.normalize("NFC"));
306
+ if (!natives) return undefined;
307
+ const own =
308
+ font === undefined ? undefined : natives.find((n) => n.map === map && n.font === font);
309
+ return (own ?? natives.find((n) => n.map === map && n.font === undefined))?.text;
310
+ };
311
+ }
312
+
313
+ /**
314
+ * What a run, a passage or a listing is mapped by.
315
+ *
316
+ * @typedef {object} Mapping
317
+ * @property {import("./text-maps.mjs").TextMap} map - The map.
318
+ * @property {string|undefined} font - The face, whose own rows come first.
319
+ * @property {((word: string) => string|undefined)|undefined} native - The
320
+ * language's native forms under the map in the face.
321
+ */
322
+
323
+ /**
324
+ * The one path from a map, a face and a language to what rewrites the text.
325
+ *
326
+ * @param {{maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap>, lexicons?: ReadonlyMap<string, Lexicon>}} ctx -
327
+ * The run context.
328
+ * @param {{map: string, font?: string, lang?: string}} keys - The map, the face
329
+ * and the language.
330
+ * @returns {Mapping|undefined} The mapping, or `undefined` for an unknown map.
331
+ */
332
+ export function mappingFor(ctx, { map, font, lang }) {
333
+ const textMap = ctx?.maps?.get(map);
334
+ if (!textMap) return undefined;
335
+ const lexicon = lang === undefined ? undefined : ctx.lexicons?.get(lang);
336
+ return { map: textMap, font, native: nativeLookup(lexicon, textMap.name, font) };
337
+ }
338
+
339
+ /**
340
+ * Why a face is not one a map is for, or `undefined` where it is.
341
+ *
342
+ * @param {import("./text-maps.mjs").TextMap} map - The map.
343
+ * @param {string} font - The face's slug.
344
+ * @returns {string|undefined} The problem.
345
+ */
346
+ export function fontProblem(map, font) {
347
+ if (!map.fonts.size || map.fonts.has(font)) return undefined;
348
+ return `\`${font}\` is not a font of the map \`${map.name}\`; it is for ${said([...map.fonts.keys()])}`;
349
+ }
350
+
351
+ /**
352
+ * The mapping a command uses, or what is wrong with what it was given. A map
353
+ * listing one face defaults to it; one listing more needs `font`.
354
+ *
355
+ * @param {object} ctx - The run context.
356
+ * @param {{map: string, font?: string, lang?: string}} keys - What was asked for.
357
+ * @returns {{mapping: Mapping}|{problem: string, about: "map"|"lang"|"font"}} The mapping or the problem.
358
+ */
359
+ function commandMapping(ctx, { map, font, lang }) {
360
+ const textMap = ctx?.maps?.get(map);
361
+ if (!textMap) {
362
+ const names = [...(ctx?.maps?.keys() ?? [])];
363
+ const near = nearest(map, names);
364
+ return {
365
+ about: "map",
366
+ problem:
367
+ `\`${map}\` is not a map: a map is named by the \`data.map.name\` of a mapping note` +
368
+ (near ? `; did you mean \`${near}\`?`
369
+ : names.length ? `; the maps are ${said(names)}`
370
+ : "; no note declares one"),
371
+ };
372
+ }
373
+ if (lang !== undefined && !Object.hasOwn(ctx.languages ?? {}, lang)) {
374
+ const near = nearest(lang, Object.keys(ctx.languages ?? {}));
375
+ return {
376
+ about: "lang",
377
+ problem:
378
+ `\`${lang}\` is not a declared language (\`languages\` in ${CONFIG})` +
379
+ (near ? `; did you mean \`${near}\`?` : ""),
380
+ };
381
+ }
382
+ let face = font;
383
+ if (face === undefined && textMap.fonts.size === 1) face = [...textMap.fonts.keys()][0];
384
+ if (face === undefined && textMap.fonts.size > 1)
385
+ return {
386
+ about: "font",
387
+ problem: `the map \`${map}\` is for ${said([...textMap.fonts.keys()])}; name one with \`--font\``,
388
+ };
389
+ if (face !== undefined) {
390
+ const problem = fontProblem(textMap, face);
391
+ if (problem) return { about: "font", problem };
392
+ }
393
+ return { mapping: /** @type {Mapping} */ (mappingFor(ctx, { map, font: face, lang })) };
394
+ }
395
+
396
+ /**
397
+ * What is wrong with a capitalised word whose native form the rows cannot
398
+ * capitalise.
399
+ *
400
+ * @param {string} word - The word as written.
401
+ * @param {string} lower - The lowercase form the lexicon states.
402
+ * @param {string|undefined} lang - The language.
403
+ * @param {string} map - The map's name.
404
+ * @returns {string} The message.
405
+ */
406
+ export function uncapitalisedMessage(word, lower, lang, map) {
407
+ return (
408
+ `\`${word}\` is capitalised, and the native form the lexicon of \`${lang}\` states for ` +
409
+ `\`${lower}\` under the map \`${map}\` does not begin as the rows spell its first letter, ` +
410
+ `so the rows cannot capitalise it; state \`${word}\` as a word of its own with its native form`
411
+ );
412
+ }
413
+
414
+ /**
415
+ * Map a passage exactly as the build maps a run with the same keys.
416
+ *
417
+ * @param {object} ctx - The run context.
418
+ * @param {{map: string, font?: string, lang?: string}} keys - The map, the face
419
+ * and the language.
420
+ * @param {string} text - The passage, with no escapes.
421
+ * @returns {{text: string}|{problem: string, about: string}} The mapped text, or the problem.
422
+ */
423
+ export function transliterate(ctx, keys, text) {
424
+ const found = commandMapping(ctx, keys);
425
+ if ("problem" in found) return { problem: found.problem, about: found.about };
426
+ const { map, font, native } = found.mapping;
427
+ const mapped = applyMap(map, text, { font, native });
428
+ const piece = mapped.pieces.find((one) => one.uncapitalised !== undefined);
429
+ if (piece)
430
+ return {
431
+ about: "word",
432
+ problem: uncapitalisedMessage(
433
+ piece.from,
434
+ /** @type {string} */ (piece.uncapitalised),
435
+ keys.lang,
436
+ map.name,
437
+ ),
438
+ };
439
+ return { text: mapped.text };
440
+ }
441
+
442
+ /**
443
+ * Every word of a language's lexicon with its native form under a map: the
444
+ * stated exception, else the rows' spelling, each exactly as the build gives
445
+ * it.
446
+ *
447
+ * @param {object} ctx - The run context.
448
+ * @param {{lang: string, map: string, font?: string}} keys - The language, the
449
+ * map and the face.
450
+ * @returns {{entries: Array<{form: string, native: string, exception: boolean, class: string|null, gloss: string|null}>}|{problem: string, about: string}}
451
+ * The words in `data.words` order, or the problem.
452
+ */
453
+ export function lexiconEntries(ctx, { lang, map, font }) {
454
+ const found = commandMapping(ctx, { map, font, lang });
455
+ if ("problem" in found) return { problem: found.problem, about: found.about };
456
+ const lexicon = ctx.lexicons?.get(lang);
457
+ if (!lexicon)
458
+ return {
459
+ about: "lang",
460
+ problem:
461
+ `\`${lang}\` has no lexicon: \`languages.${lang}\` names no language note ` +
462
+ "in its `note`",
463
+ };
464
+ const { map: textMap, font: face, native } = found.mapping;
465
+ const mapped = lexicon.words.map((word) =>
466
+ applyMap(textMap, word.form, { font: face, native }),
467
+ );
468
+ const unsound = mapped.flatMap((one) => one.pieces).find((one) => one.uncapitalised);
469
+ if (unsound)
470
+ return {
471
+ about: "word",
472
+ problem: uncapitalisedMessage(
473
+ unsound.from,
474
+ /** @type {string} */ (unsound.uncapitalised),
475
+ lang,
476
+ textMap.name,
477
+ ),
478
+ };
479
+ return {
480
+ entries: lexicon.words.map((word, i) => ({
481
+ form: word.form,
482
+ native: mapped[i].text,
483
+ exception:
484
+ native?.(word.form) !== undefined ||
485
+ (word.form.toLowerCase() !== word.form &&
486
+ native?.(word.form.toLowerCase()) !== undefined),
487
+ class: word.class,
488
+ gloss: word.gloss,
489
+ })),
490
+ };
491
+ }
492
+
493
+ /**
494
+ * Whether a shared row can be reached in a face: no plain row of the face's
495
+ * own matches at the start of its `from`, which would win there first.
496
+ *
497
+ * @param {import("./text-maps.mjs").MapRow} row - A shared row.
498
+ * @param {readonly import("./text-maps.mjs").MapRow[]} own - The face's rows.
499
+ * @returns {boolean} Whether the row can apply.
500
+ */
501
+ function reachable(row, own) {
502
+ return !own.some((mine) => !mine.before && !mine.at && row.from.startsWith(mine.from));
503
+ }
504
+
505
+ /**
506
+ * Check a mapping note's `fonts` against the installed faces: an unknown face
507
+ * and a face listed twice, at the entry, and each code point a listed face
508
+ * lacks, at the row that writes it.
509
+ *
510
+ * @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
511
+ * @param {{faces?: ReadonlyMap<string, import("./script-fonts.mjs").Face>, maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap>}} ctx -
512
+ * The run context.
513
+ * @returns {object[]} The findings.
514
+ */
515
+ export function checkMapFonts(note, ctx) {
516
+ const value = note?.fm?.data?.map;
517
+ if (!isMap(value) || !Array.isArray(value.fonts)) return [];
518
+ const raw = note.raw ?? "";
519
+ const finding = (keyPath, message, key = false) => ({
520
+ file: note.file,
521
+ ...positionOfFrontmatterPath(raw, ["data", "map", ...keyPath], { key }),
522
+ severity: "error",
523
+ message,
524
+ });
525
+ const faces = ctx?.faces ?? new Map();
526
+ const findings = [];
527
+ const first = new Map();
528
+ const listed = [];
529
+ value.fonts.forEach((entry, at) => {
530
+ if (!isMap(entry) || typeof entry.font !== "string") return;
531
+ const slug = entry.font;
532
+ if (GENERIC_FAMILIES.includes(slug)) return;
533
+ if (first.has(slug)) {
534
+ findings.push(
535
+ finding(
536
+ ["fonts", at],
537
+ `\`data.map.fonts[${at}]\` lists the font \`${slug}\` a second time; the first is ` +
538
+ `\`data.map.fonts[${first.get(slug)}]\``,
539
+ ),
540
+ );
541
+ return;
542
+ }
543
+ first.set(slug, at);
544
+ if (!faces.has(slug)) {
545
+ const near = nearest(slug, [...faces.keys()]);
546
+ findings.push(
547
+ finding(
548
+ ["fonts", at, "font"],
549
+ `\`${slug}\` is not a font: \`data.map.fonts[${at}].font\` names ` +
550
+ (faces.size ? "an installed face" : (
551
+ `a face of ${FONTS_PACKAGE}, which this package has not installed`
552
+ )) +
553
+ (near ? `; did you mean \`${near}\`?` : ""),
554
+ ),
555
+ );
556
+ return;
557
+ }
558
+ listed.push({ slug, at });
559
+ });
560
+ const compiled = ctx?.maps?.get(value.name);
561
+ if (!compiled) return findings;
562
+ for (const { slug, at } of listed) {
563
+ const face = /** @type {import("./script-fonts.mjs").Face} */ (faces.get(slug));
564
+ const own = compiled.fonts.get(slug)?.rows ?? [];
565
+ const gaps = (rows, keyPath, label, message) =>
566
+ listOf(rows).forEach((authored, j) => {
567
+ const to = Array.isArray(authored) ? authored[1] : authored?.to;
568
+ if (typeof to !== "string") return;
569
+ const from = Array.isArray(authored) ? authored[0] : authored?.from;
570
+ if (keyPath[0] === "rows" && !reachable({ from: String(from), to }, own)) return;
571
+ const seen = new Set();
572
+ for (const char of to) {
573
+ const point = /** @type {number} */ (char.codePointAt(0));
574
+ if (/\s/u.test(char) || face.draws.has(point) || seen.has(point)) continue;
575
+ seen.add(point);
576
+ findings.push(
577
+ finding(
578
+ [...keyPath, j],
579
+ `\`${label}[${j}]\` writes ${codeName(point)}, which the font \`${slug}\`${message} does not draw`,
580
+ ),
581
+ );
582
+ }
583
+ });
584
+ gaps(value.fonts[at].rows, ["fonts", at, "rows"], `data.map.fonts[${at}].rows`, "");
585
+ gaps(value.rows, ["rows"], "data.map.rows", ", listed in `data.map.fonts`,");
586
+ }
587
+ return findings;
588
+ }
589
+
590
+ /**
591
+ * Check a language note's native forms against the maps and faces: an unknown
592
+ * map, a face the map is not for, a second form for one map and face, a form
593
+ * that states what the rows already give, and a code point a face it applies
594
+ * in does not draw. The shape of each entry is the data-key check's.
595
+ *
596
+ * @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
597
+ * @param {{faces?: ReadonlyMap<string, import("./script-fonts.mjs").Face>, maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap>}} ctx -
598
+ * The run context.
599
+ * @returns {object[]} The findings.
600
+ */
601
+ export function checkLexiconNatives(note, ctx) {
602
+ const fm = note?.fm ?? {};
603
+ if (!isLanguage(fm) || !isMap(fm.data)) return [];
604
+ const raw = note.raw ?? "";
605
+ const finding = (keyPath, message, key = false) => ({
606
+ file: note.file,
607
+ ...positionOfFrontmatterPath(raw, ["data", "words", ...keyPath], { key }),
608
+ severity: "error",
609
+ message,
610
+ });
611
+ const maps = ctx?.maps ?? new Map();
612
+ const faces = ctx?.faces ?? new Map();
613
+ const findings = [];
614
+ listOf(fm.data.words).forEach((word, i) => {
615
+ if (!isMap(word) || !Array.isArray(word.native)) return;
616
+ const form = typeof word.form === "string" ? word.form : "";
617
+ const stated = new Map();
618
+ const entries = word.native.map((entry) =>
619
+ isMap(entry) && typeof entry.map === "string" && typeof entry.text === "string" ?
620
+ {
621
+ map: entry.map,
622
+ font: typeof entry.font === "string" ? entry.font : undefined,
623
+ text: entry.text,
624
+ }
625
+ : undefined,
626
+ );
627
+ entries.forEach((entry, j) => {
628
+ if (!entry) return;
629
+ const label = `data.words[${i}].native[${j}]`;
630
+ const map = maps.get(entry.map);
631
+ if (!map) {
632
+ const near = nearest(entry.map, [...maps.keys()]);
633
+ findings.push(
634
+ finding(
635
+ [i, "native", j, "map"],
636
+ `\`${label}.map\` is \`${entry.map}\`, which is not a map: a native form names ` +
637
+ "the `data.map.name` of a mapping note" +
638
+ (near ? `; did you mean \`${near}\`?` : ""),
639
+ ),
640
+ );
641
+ return;
642
+ }
643
+ if (entry.font !== undefined && map.fonts.size && !map.fonts.has(entry.font)) {
644
+ findings.push(
645
+ finding(
646
+ [i, "native", j, "font"],
647
+ `\`${label}.font\` is \`${entry.font}\`, which is not a font of the map ` +
648
+ `\`${map.name}\`; it is for ${said([...map.fonts.keys()])}`,
649
+ ),
650
+ );
651
+ return;
652
+ }
653
+ const slot = `${entry.map}\u0000${entry.font ?? ""}`;
654
+ if (stated.has(slot))
655
+ findings.push(
656
+ finding(
657
+ [i, "native", j],
658
+ `\`${label}\` states a second native form for the map \`${map.name}\`` +
659
+ (entry.font === undefined ? "" : ` in the font \`${entry.font}\``) +
660
+ `; the first is \`data.words[${i}].native[${stated.get(slot)}]\``,
661
+ ),
662
+ );
663
+ else stated.set(slot, j);
664
+ const fonts =
665
+ entry.font !== undefined ? [entry.font]
666
+ : map.fonts.size ? [...map.fonts.keys()]
667
+ : [undefined];
668
+ if (fonts.every((font) => applyMap(map, form, { font }).text === entry.text))
669
+ findings.push(
670
+ finding(
671
+ [i, "native", j],
672
+ `\`${label}\` states what the rows already give: the map \`${map.name}\` ` +
673
+ `spells \`${form}\` so ` +
674
+ (entry.font !== undefined ? `in the font \`${entry.font}\``
675
+ : map.fonts.size > 1 ? "in every font it is for"
676
+ : map.fonts.size ? `in the font \`${[...map.fonts.keys()][0]}\``
677
+ : "by its rows") +
678
+ "; a native form is stated only where the rows spell the word differently",
679
+ ),
680
+ );
681
+ // The faces the form is written in: its own, or every listed face
682
+ // no form stated for that face overrides.
683
+ const applies =
684
+ entry.font !== undefined ?
685
+ [entry.font]
686
+ : [...map.fonts.keys()].filter(
687
+ (font) =>
688
+ !entries.some(
689
+ (other) => other && other.map === entry.map && other.font === font,
690
+ ),
691
+ );
692
+ for (const slug of applies) {
693
+ const face = faces.get(slug);
694
+ if (!face) continue;
695
+ const seen = new Set();
696
+ for (const char of entry.text) {
697
+ const point = /** @type {number} */ (char.codePointAt(0));
698
+ if (/\s/u.test(char) || face.draws.has(point) || seen.has(point)) continue;
699
+ seen.add(point);
700
+ findings.push(
701
+ finding(
702
+ [i, "native", j],
703
+ `\`${label}\` holds ${codeName(point)}, which the font \`${slug}\`, ` +
704
+ `listed in the map \`${map.name}\`, does not draw`,
705
+ ),
706
+ );
707
+ }
708
+ }
709
+ });
710
+ });
711
+ return findings;
712
+ }
713
+
714
+ /** Every column of the `natives` relation, in order. */
715
+ export const NATIVE_COLUMNS = Object.freeze([
716
+ "language",
717
+ "word",
718
+ "form",
719
+ "native",
720
+ "map",
721
+ "font",
722
+ "text",
723
+ ]);
724
+
725
+ /**
726
+ * One row per native form of a language note's word, for the `natives`
727
+ * relation: a text is a value of a row, never a struct key, so forms that
728
+ * differ only in case stay apart.
729
+ *
730
+ * @param {Iterable<Record<string, any>>} records - Index records.
731
+ * @returns {Array<Record<string, string|number|null>>} The rows; `word` is the
732
+ * word's 0-based place in `data.words`, `native` the form's in its `native`.
733
+ */
734
+ export function nativeRows(records) {
735
+ const rows = [];
736
+ for (const record of records) {
737
+ if (!isLanguageRecord(record)) continue;
738
+ const language = noteAddressOf(record);
739
+ if (!language) continue;
740
+ listOf(record.data?.words).forEach((word, position) => {
741
+ if (!isMap(word) || typeof word.form !== "string") return;
742
+ listOf(word.native).forEach((entry, at) => {
743
+ if (
744
+ !isMap(entry) ||
745
+ typeof entry.map !== "string" ||
746
+ typeof entry.text !== "string"
747
+ )
748
+ return;
749
+ rows.push({
750
+ language,
751
+ word: position,
752
+ form: word.form,
753
+ native: at,
754
+ map: entry.map,
755
+ font: typeof entry.font === "string" ? entry.font : null,
756
+ text: entry.text,
757
+ });
758
+ });
759
+ });
760
+ }
761
+ return rows;
762
+ }