@heroiclands/package-build 23.12.6 → 23.12.8

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