@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
@@ -1,654 +0,0 @@
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
- * **Mapping notes: code point sequences rewritten into code points.** A `doc`
16
- * note's `data.map` names a mapping and lists its rows; a run marked `map=`
17
- * with that name has its text rewritten by them before it is set.
18
- *
19
- * ```yaml
20
- * data:
21
- * map:
22
- * name: vyl-semaron
23
- * vowels: "aeiouAEIOU"
24
- * rows:
25
- * - [a, "\U000F0230"]
26
- * - [aa, "\U000F0248"]
27
- * - {from: y, to: "\U000F0237", before: consonant}
28
- * ```
29
- *
30
- * - **A row** is `[from, to]`, or `{from, to, before?, at?}`. `from` is one or
31
- * more characters and `to` any number, none included.
32
- * - **The longest `from` wins.** Among rows of one length, a row with a
33
- * condition that holds comes before a row with none, and otherwise the
34
- * earlier row wins. A row whose condition fails is skipped.
35
- * - **`before: vowel`** holds when the next character is one of the map's
36
- * `vowels`; **`before: consonant`** when it is any other letter. Neither
37
- * holds at the end of the text.
38
- * - **`at: start`** holds at the first character of the text, or after
39
- * whitespace or punctuation; **`at: end`** mirrors it at the match's end.
40
- * - **A character no row matches passes through unchanged.**
41
- *
42
- * - **A word with a native form.** Given a lookup from a language's lexicon,
43
- * each word of the text (see {@link module:engine/word-pattern.WORD}) is
44
- * looked up as written and then in lowercase; a word found is written by its
45
- * native form as one piece, and every other word, and everything between
46
- * words, is spelled by the rows.
47
- * - **A capital on a word found in lowercase** is the rows' own: the word's
48
- * first sound is mapped as written and in lowercase, and where the native
49
- * form begins with the lowercase result, that beginning is replaced by the
50
- * capital one. Where the native form begins otherwise, the piece is marked
51
- * `uncapitalised`, which a run reports; the capitalised word is then stated
52
- * as a lexicon word of its own.
53
- *
54
- * - **A logographic map** (`logographic: true`) writes every word, a word
55
- * being a match of the word pattern that holds a letter, by the writing a
56
- * lookup gives it (see {@link module:engine/logographic}), and its rows
57
- * write only the text between words: spaces, points, and anything else
58
- * with no letter. A word the lookup gives no writing is written as typed
59
- * and marked `unwritten`, which a run reports. A row of such a map whose
60
- * `from` holds a letter could never apply, and is refused.
61
- *
62
- * `rows` is a list, never a keyed map, so `A` and `a` are two rows: the index
63
- * carries them as rows of the `maps` relation, never as struct keys.
64
- *
65
- * @module
66
- */
67
-
68
- import { positionOfFrontmatterPath } from "./diagnostics.mjs";
69
- import { isNoteRecord } from "./index-records.mjs";
70
- import { WORD } from "./word-pattern.mjs";
71
-
72
- /** What a map's `name` may be. */
73
- export const MAP_NAME = /^[a-z0-9-]+$/;
74
-
75
- /** Each condition a row may carry, and the values it takes. */
76
- export const MAP_CONDITIONS = Object.freeze({
77
- before: Object.freeze(["vowel", "consonant"]),
78
- at: Object.freeze(["start", "end"]),
79
- });
80
-
81
- /** The generic families, which a map's `fonts` may not list. */
82
- export const GENERIC_FAMILIES = Object.freeze(["mono", "serif", "sans"]);
83
-
84
- /** The keys a row written as a map takes. */
85
- const ROW_KEYS = Object.freeze(["from", "to", "before", "at"]);
86
-
87
- /** A letter, for `before: consonant`. */
88
- const LETTER = /\p{L}/u;
89
-
90
- /** What `at:` reads as a boundary beside a match. */
91
- const BOUNDARY = /[\s\p{P}\p{S}]/u;
92
-
93
- /**
94
- * One row, read.
95
- *
96
- * @typedef {object} MapRow
97
- * @property {string} from - The characters it matches.
98
- * @property {string} to - What it writes in their place.
99
- * @property {"vowel"|"consonant"} [before] - What must follow the match.
100
- * @property {"start"|"end"} [at] - Where the match must sit.
101
- */
102
-
103
- /**
104
- * A map ready to apply.
105
- *
106
- * @typedef {object} TextMap
107
- * @property {string} name - Its name.
108
- * @property {boolean} logographic - Whether it writes words from the lexicon
109
- * alone, its rows only between them.
110
- * @property {ReadonlySet<string>} vowels - The characters `before: vowel` reads.
111
- * @property {readonly MapRow[]} rows - Its well-formed shared rows, in authored order.
112
- * @property {ReadonlyMap<string, readonly MapRow[]>} byFirst - Its shared rows
113
- * by the first character of `from`, longest first, conditioned before plain.
114
- * @property {ReadonlyMap<string, {rows: readonly MapRow[], byFirst: ReadonlyMap<string, readonly MapRow[]>}>} fonts -
115
- * Each listed face by slug, in listed order, with its own rows.
116
- */
117
-
118
- /**
119
- * How a text is mapped beyond the rows.
120
- *
121
- * @typedef {object} MapOptions
122
- * @property {string} [font] - The face the text is set in, whose own rows are
123
- * tried first.
124
- * @property {(word: string) => string|undefined} [native] - A word's native
125
- * form, for the word exactly as given, or `undefined` where it states none.
126
- * @property {(word: string) => import("./logographic.mjs").Writing} [writing] -
127
- * A word's writing under a logographic map; without one, no word has one.
128
- */
129
-
130
- /**
131
- * Whether a value is a plain map.
132
- *
133
- * @param {unknown} value - Anything.
134
- * @returns {boolean} Whether it is an object that is not a list.
135
- */
136
- function isMap(value) {
137
- return value !== null && typeof value === "object" && !Array.isArray(value);
138
- }
139
-
140
- /**
141
- * Read one authored row.
142
- *
143
- * @param {unknown} row - The row as YAML gave it.
144
- * @returns {{row: MapRow}|{problem: string, key?: string}} The row, or what is
145
- * wrong with it and the key the fault sits at.
146
- */
147
- export function readMapRow(row) {
148
- if (Array.isArray(row)) {
149
- if (row.length !== 2 || typeof row[0] !== "string" || typeof row[1] !== "string")
150
- return {
151
- problem: "is a list, and a row written as a list is two strings, `[from, to]`",
152
- };
153
- if (row[0] === "")
154
- return { problem: "has an empty `from`; a row matches one or more characters" };
155
- return { row: { from: row[0], to: row[1] } };
156
- }
157
- if (!isMap(row)) return { problem: "is neither `[from, to]` nor `{from, to, before?, at?}`" };
158
- const map = /** @type {Record<string, unknown>} */ (row);
159
- for (const key of Object.keys(map))
160
- if (!ROW_KEYS.includes(key))
161
- return {
162
- problem: `has \`${key}\`, which is not a row key; a row takes \`from\`, \`to\`, \`before\` and \`at\``,
163
- key,
164
- };
165
- if (typeof map.from !== "string" || map.from === "")
166
- return {
167
- problem: "needs `from`, the one or more characters the row matches",
168
- ...(map.from === undefined ? {} : { key: "from" }),
169
- };
170
- if (typeof map.to !== "string")
171
- return {
172
- problem: "needs `to`, the characters the row writes, which may be empty",
173
- ...(map.to === undefined ? {} : { key: "to" }),
174
- };
175
- const read = /** @type {MapRow} */ ({ from: map.from, to: map.to });
176
- for (const [condition, allowed] of Object.entries(MAP_CONDITIONS)) {
177
- const value = map[condition];
178
- if (value === undefined) continue;
179
- if (typeof value !== "string" || !allowed.includes(value))
180
- return {
181
- problem: `has \`${condition}: ${String(value)}\`; \`${condition}\` is ${allowed.map((word) => `\`${word}\``).join(" or ")}`,
182
- key: condition,
183
- };
184
- read[condition] = value;
185
- }
186
- return { row: read };
187
- }
188
-
189
- /**
190
- * Rows by the first character of `from`, longest first, conditioned before
191
- * plain, then in authored order.
192
- *
193
- * @param {readonly MapRow[]} rows - Well-formed rows.
194
- * @returns {ReadonlyMap<string, readonly MapRow[]>} The index.
195
- */
196
- function indexRows(rows) {
197
- const byFirst = new Map();
198
- rows.forEach((row, order) => {
199
- const first = String.fromCodePoint(/** @type {number} */ (row.from.codePointAt(0)));
200
- if (!byFirst.has(first)) byFirst.set(first, []);
201
- byFirst.get(first).push({ row, order });
202
- });
203
- for (const [first, list] of byFirst) {
204
- list.sort(
205
- (a, b) =>
206
- b.row.from.length - a.row.from.length ||
207
- Number(Boolean(b.row.before || b.row.at)) -
208
- Number(Boolean(a.row.before || a.row.at)) ||
209
- a.order - b.order,
210
- );
211
- byFirst.set(first, Object.freeze(list.map(({ row }) => row)));
212
- }
213
- return byFirst;
214
- }
215
-
216
- /**
217
- * The well-formed rows of an authored list.
218
- *
219
- * @param {unknown} list - The rows as YAML gave them.
220
- * @returns {readonly MapRow[]} The rows that read.
221
- */
222
- function readRows(list) {
223
- const rows = [];
224
- for (const authored of Array.isArray(list) ? list : []) {
225
- const read = readMapRow(authored);
226
- if ("row" in read) rows.push(Object.freeze(read.row));
227
- }
228
- return Object.freeze(rows);
229
- }
230
-
231
- /**
232
- * Compile a `data.map` value, keeping its well-formed rows.
233
- *
234
- * @param {unknown} value - The note's `data.map`.
235
- * @returns {TextMap|undefined} The map, or `undefined` when it has no valid name.
236
- */
237
- export function compileMap(value) {
238
- if (!isMap(value)) return undefined;
239
- const map = /** @type {Record<string, unknown>} */ (value);
240
- if (typeof map.name !== "string" || !MAP_NAME.test(map.name)) return undefined;
241
- const vowels = new Set(typeof map.vowels === "string" ? Array.from(map.vowels) : []);
242
- const rows = readRows(map.rows);
243
- const fonts = new Map();
244
- for (const entry of Array.isArray(map.fonts) ? map.fonts : []) {
245
- if (!isMap(entry) || typeof entry.font !== "string" || fonts.has(entry.font)) continue;
246
- const own = readRows(entry.rows);
247
- fonts.set(entry.font, Object.freeze({ rows: own, byFirst: indexRows(own) }));
248
- }
249
- return Object.freeze({
250
- name: map.name,
251
- logographic: map.logographic === true,
252
- vowels,
253
- rows,
254
- byFirst: indexRows(rows),
255
- fonts,
256
- });
257
- }
258
-
259
- /**
260
- * Whether a row's conditions hold for a match.
261
- *
262
- * @param {MapRow} row - The row.
263
- * @param {string} text - The whole text.
264
- * @param {number} from - The match's first UTF-16 offset.
265
- * @param {number} to - The offset past the match.
266
- * @param {ReadonlySet<string>} vowels - The map's vowels.
267
- * @returns {boolean} Whether every condition holds.
268
- */
269
- function holds(row, text, from, to, vowels) {
270
- if (row.before) {
271
- const next = text.codePointAt(to);
272
- if (next === undefined) return false;
273
- const char = String.fromCodePoint(next);
274
- const vowel = vowels.has(char);
275
- if (row.before === "vowel" ? !vowel : vowel || !LETTER.test(char)) return false;
276
- }
277
- if (row.at === "start" && from > 0 && !BOUNDARY.test(text[from - 1])) return false;
278
- if (row.at === "end" && to < text.length && !BOUNDARY.test(text[to])) return false;
279
- return true;
280
- }
281
-
282
- /**
283
- * The row that matches at an offset: the font's own rows first, then the
284
- * shared ones.
285
- *
286
- * @param {TextMap} map - The map.
287
- * @param {string|undefined} font - The face.
288
- * @param {string} text - The whole text.
289
- * @param {number} at - The offset.
290
- * @param {number} limit - The offset no match may run past.
291
- * @returns {MapRow|undefined} The row.
292
- */
293
- function rowAt(map, font, text, at, limit) {
294
- const char = String.fromCodePoint(/** @type {number} */ (text.codePointAt(at)));
295
- const tiers = [font === undefined ? undefined : map.fonts?.get(font)?.byFirst, map.byFirst];
296
- for (const byFirst of tiers) {
297
- const row = byFirst
298
- ?.get(char)
299
- ?.find(
300
- (candidate) =>
301
- at + candidate.from.length <= limit &&
302
- text.startsWith(candidate.from, at) &&
303
- holds(candidate, text, at, at + candidate.from.length, map.vowels),
304
- );
305
- if (row) return row;
306
- }
307
- return undefined;
308
- }
309
-
310
- /**
311
- * Apply the rows alone.
312
- *
313
- * @param {TextMap|undefined} map - The map.
314
- * @param {string} text - The text.
315
- * @param {string|undefined} font - The face.
316
- * @param {ReadonlyMap<number, {end: number, to: string, uncapitalised?: string, unwritten?: object}>} words -
317
- * Words written whole, by offset.
318
- * @returns {{text: string, pieces: Array<{at: number, from: string, to: string, row: boolean, native?: boolean, uncapitalised?: string, unwritten?: object}>}}
319
- * As {@link applyMap}.
320
- */
321
- function applyRows(map, text, font, words) {
322
- const pieces = [];
323
- let out = "";
324
- const starts = [...words.keys()].sort((a, b) => a - b);
325
- let next = 0;
326
- for (let at = 0; at < text.length;) {
327
- const word = words.get(at);
328
- if (word) {
329
- pieces.push({
330
- at,
331
- from: text.slice(at, word.end),
332
- to: word.to,
333
- ...(word.unwritten === undefined ?
334
- { row: true, native: true }
335
- : { row: false, unwritten: word.unwritten }),
336
- ...(word.uncapitalised === undefined ? {} : { uncapitalised: word.uncapitalised }),
337
- });
338
- out += word.to;
339
- at = word.end;
340
- continue;
341
- }
342
- while (next < starts.length && starts[next] <= at) next++;
343
- const limit = next < starts.length ? starts[next] : text.length;
344
- const row = map ? rowAt(map, font, text, at, limit) : undefined;
345
- if (row) {
346
- pieces.push({ at, from: row.from, to: row.to, row: true });
347
- out += row.to;
348
- at += row.from.length;
349
- } else {
350
- const char = String.fromCodePoint(/** @type {number} */ (text.codePointAt(at)));
351
- pieces.push({ at, from: char, to: char, row: false });
352
- out += char;
353
- at += char.length;
354
- }
355
- }
356
- return { text: out, pieces };
357
- }
358
-
359
- /**
360
- * A native form found for a word in lowercase, given the word's capital as the
361
- * rows write it: the rows map the word's first sound as written and in
362
- * lowercase, and where the native form begins with the lowercase result, that
363
- * beginning becomes the capital one.
364
- *
365
- * @param {TextMap} map - The map.
366
- * @param {string|undefined} font - The face.
367
- * @param {string} word - The word as written.
368
- * @param {string} native - The native form of its lowercase.
369
- * @returns {string|undefined} The native form, capitalised; `undefined` where
370
- * it does not begin as the rows begin the word, so the rows cannot say how.
371
- */
372
- export function capitalNative(map, font, word, native) {
373
- const none = new Map();
374
- const lower = applyRows(map, word.toLowerCase(), font, none).pieces[0];
375
- const upper = applyRows(map, word, font, none).pieces[0];
376
- if (!lower || !upper || lower.from.length !== upper.from.length) return undefined;
377
- if (!native.startsWith(lower.to)) return undefined;
378
- return upper.to + native.slice(lower.to.length);
379
- }
380
-
381
- /**
382
- * Apply a map to text.
383
- *
384
- * @param {TextMap|undefined} map - The map; without one the text is returned
385
- * as it is.
386
- * @param {string} text - The text, with no escapes in it.
387
- * @param {MapOptions} [options] - The face, and the native forms of words.
388
- * @returns {{text: string, pieces: Array<{at: number, from: string, to: string, row: boolean, native?: boolean, unwritten?: object}>}}
389
- * The rewritten text, and each piece of it with the UTF-16 offset in `text`
390
- * it came from: a matched row's `from` and what it wrote, a word and its
391
- * native form (`native: true`), or one character that passed through. A
392
- * capitalised word whose native form the rows cannot capitalise is written
393
- * as stated and carries `uncapitalised`, the lowercase form it was found by.
394
- * Under a logographic map, a word with no writing is written as typed and
395
- * carries `unwritten`, why it has none.
396
- */
397
- export function applyMap(map, text, { font, native, writing } = {}) {
398
- const words = new Map();
399
- if (map?.logographic) {
400
- for (const match of text.matchAll(WORD)) {
401
- const word = match[0];
402
- if (!LETTER.test(word)) continue;
403
- const found = writing ? writing(word) : { missing: true, noLexicon: true };
404
- words.set(
405
- match.index,
406
- "text" in found ?
407
- { end: match.index + word.length, to: found.text }
408
- : { end: match.index + word.length, to: word, unwritten: found },
409
- );
410
- }
411
- return applyRows(map, text, font, words);
412
- }
413
- if (map && native)
414
- for (const match of text.matchAll(WORD)) {
415
- const word = match[0];
416
- const exact = native(word);
417
- if (exact !== undefined) {
418
- words.set(match.index, { end: match.index + word.length, to: exact });
419
- continue;
420
- }
421
- const lower = word.toLowerCase();
422
- const found = lower === word ? undefined : native(lower);
423
- if (found === undefined) continue;
424
- const first = String.fromCodePoint(/** @type {number} */ (word.codePointAt(0)));
425
- const capital = first !== first.toLowerCase();
426
- const capitalised = capital ? capitalNative(map, font, word, found) : found;
427
- words.set(match.index, {
428
- end: match.index + word.length,
429
- to: capitalised ?? found,
430
- ...(capitalised === undefined ? { uncapitalised: lower } : {}),
431
- });
432
- }
433
- return applyRows(map, text, font, words);
434
- }
435
-
436
- /**
437
- * Every map the index records declare, by name. The first note to declare a
438
- * name keeps it; the duplicate is the lint's to report.
439
- *
440
- * @param {Iterable<Record<string, any>>|undefined} records - Index records.
441
- * @returns {Map<string, TextMap>} The maps.
442
- */
443
- export function mapsFromRecords(records) {
444
- const maps = new Map();
445
- for (const record of records ?? []) {
446
- if (!record || !isNoteRecord(record)) continue;
447
- const map = compileMap(record.data?.map);
448
- if (map && !maps.has(map.name)) maps.set(map.name, map);
449
- }
450
- return maps;
451
- }
452
-
453
- /** Every column of the `maps` relation, in order. */
454
- export const MAP_COLUMNS = Object.freeze([
455
- "owner",
456
- "name",
457
- "font",
458
- "row",
459
- "from",
460
- "to",
461
- "before",
462
- "at",
463
- ]);
464
-
465
- /**
466
- * One row per well-formed map row, for the `maps` relation: the shared rows
467
- * with no `font`, then each listed face's own rows with its slug.
468
- *
469
- * @param {Iterable<Record<string, any>>} records - Index records.
470
- * @param {(record: Record<string, any>) => string|null} ownerOf - A record's
471
- * page Address.
472
- * @returns {Array<Record<string, string|number|null>>} The rows, in record
473
- * then authored order; `row` is the row's 1-based place in its list.
474
- */
475
- export function mapRows(records, ownerOf) {
476
- const out = [];
477
- for (const record of records) {
478
- if (!record || !isNoteRecord(record)) continue;
479
- const value = record.data?.map;
480
- if (!isMap(value) || typeof value.name !== "string") continue;
481
- const owner = ownerOf(record);
482
- const lists = [
483
- [null, value.rows],
484
- ...(Array.isArray(value.fonts) ? value.fonts : [])
485
- .filter((entry) => isMap(entry) && typeof entry.font === "string")
486
- .map((entry) => [entry.font, entry.rows]),
487
- ];
488
- for (const [font, rows] of lists) {
489
- if (!Array.isArray(rows)) continue;
490
- rows.forEach((authored, index) => {
491
- const read = readMapRow(authored);
492
- if (!("row" in read)) return;
493
- out.push({
494
- owner,
495
- name: value.name,
496
- font,
497
- row: index + 1,
498
- from: read.row.from,
499
- to: read.row.to,
500
- before: read.row.before ?? null,
501
- at: read.row.at ?? null,
502
- });
503
- });
504
- }
505
- }
506
- return out;
507
- }
508
-
509
- /** The notes declaring each map name, read once per lint index. */
510
- const declaredNames = new WeakMap();
511
-
512
- /**
513
- * Each map name the index's notes declare, and the notes declaring it.
514
- *
515
- * @param {{notes?: Iterable<{file: string, fm?: Record<string, any>}>}} index - The lint index.
516
- * @returns {Map<string, string[]>} Name → the files that declare it.
517
- */
518
- function namesIn(index) {
519
- const known = declaredNames.get(index);
520
- if (known) return known;
521
- const names = new Map();
522
- for (const note of index.notes ?? []) {
523
- const name = note?.fm?.data?.map?.name;
524
- if (typeof name !== "string") continue;
525
- if (!names.has(name)) names.set(name, []);
526
- names.get(name).push(note.file);
527
- }
528
- declaredNames.set(index, names);
529
- return names;
530
- }
531
-
532
- /**
533
- * Check a note's `data.map`: its name, that no other note declares it, each
534
- * row written as a list, a `before` condition on a map with no `vowels`, an
535
- * empty `fonts`, a generic family in it, and a row of a logographic map that
536
- * matches a letter. The data-key check owns every
537
- * key, its kind and the words a condition takes; whether a listed face is
538
- * installed, and draws what the map writes, is
539
- * {@link module:engine/map-lexicon.checkMapFonts}'s.
540
- *
541
- * @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
542
- * @param {{index?: {notes?: Iterable<object>}}} [context] - The lint index.
543
- * @returns {object[]} The findings, each at its key.
544
- */
545
- export function checkMapField(note, { index } = {}) {
546
- const value = note?.fm?.data?.map;
547
- if (!isMap(value)) return [];
548
- const raw = note.raw ?? "";
549
- /**
550
- * @param {Array<string|number>} keyPath - Where the fault is.
551
- * @param {string} message - What is wrong.
552
- * @param {boolean} [key] - Locate the key rather than its value.
553
- */
554
- const finding = (keyPath, message, key = false) => ({
555
- file: note.file,
556
- ...positionOfFrontmatterPath(raw, ["data", "map", ...keyPath], { key }),
557
- severity: "error",
558
- message,
559
- });
560
- const findings = [];
561
- const { name, vowels, rows, fonts, logographic } = /** @type {Record<string, unknown>} */ (
562
- value
563
- );
564
- if (typeof name === "string" && !MAP_NAME.test(name))
565
- findings.push(
566
- finding(
567
- ["name"],
568
- `\`data.map.name\` is \`${name}\`; a map's name is lowercase letters, digits and \`-\``,
569
- ),
570
- );
571
- else if (typeof name === "string" && index) {
572
- const others = (namesIn(index).get(name) ?? []).filter((file) => file !== note.file);
573
- if (others.length)
574
- findings.push(
575
- finding(
576
- ["name"],
577
- `\`data.map.name\` is \`${name}\`, which ${others.join(", ")} also declares; a map's name is unique`,
578
- ),
579
- );
580
- }
581
- if (Array.isArray(fonts)) {
582
- if (!fonts.length)
583
- findings.push(
584
- finding(
585
- ["fonts"],
586
- "`data.map.fonts` is empty; a map lists each face it is drawn in",
587
- ),
588
- );
589
- fonts.forEach((entry, at) => {
590
- if (isMap(entry) && GENERIC_FAMILIES.includes(entry.font))
591
- findings.push(
592
- finding(
593
- ["fonts", at, "font"],
594
- `\`data.map.fonts[${at}].font\` is \`${entry.font}\`, a generic family; a map lists the faces it is drawn in`,
595
- ),
596
- );
597
- });
598
- }
599
- // A row's keys, their kinds and the words a condition takes are the
600
- // data-key check's; what it cannot say is a list row's arity, an empty
601
- // `from` in one, a condition the map has no vowels for, and a letter a
602
- // logographic map's row could never meet.
603
- const hasVowels = typeof vowels === "string" && vowels !== "";
604
- const rowFindings = (list, keyPath) => {
605
- if (!Array.isArray(list)) return;
606
- const label = `data.map.${keyPath.join(".").replace(/\.(\d+)/g, "[$1]")}`;
607
- list.forEach((row, at) => {
608
- const from =
609
- Array.isArray(row) ? row[0]
610
- : isMap(row) ? row.from
611
- : undefined;
612
- const letter = typeof from === "string" ? from.match(LETTER)?.[0] : undefined;
613
- if (logographic === true && letter !== undefined)
614
- findings.push(
615
- finding(
616
- [...keyPath, at],
617
- `\`${label}[${at}]\` matches \`${letter}\`, a letter, and the rows of a ` +
618
- "logographic map write only the text between words",
619
- ),
620
- );
621
- if (Array.isArray(row)) {
622
- if (row.length !== 2)
623
- findings.push(
624
- finding(
625
- [...keyPath, at],
626
- `\`${label}[${at}]\` is a list, and a row written as a list is two strings, \`[from, to]\``,
627
- ),
628
- );
629
- else if (row[0] === "")
630
- findings.push(
631
- finding(
632
- [...keyPath, at],
633
- `\`${label}[${at}]\` has an empty \`from\`; a row matches one or more characters`,
634
- ),
635
- );
636
- return;
637
- }
638
- if (isMap(row) && row.before !== undefined && !hasVowels)
639
- findings.push(
640
- finding(
641
- [...keyPath, at, "before"],
642
- `\`${label}[${at}]\` has \`before: ${String(row.before)}\`, and the map states no \`vowels\` to read it by`,
643
- true,
644
- ),
645
- );
646
- });
647
- };
648
- rowFindings(rows, ["rows"]);
649
- if (Array.isArray(fonts))
650
- fonts.forEach((entry, at) => {
651
- if (isMap(entry)) rowFindings(entry.rows, ["fonts", at, "rows"]);
652
- });
653
- return findings;
654
- }