@heroiclands/package-build 24.0.0 → 24.0.2

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 (61) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/assets/preview/gitea.ini +1 -1
  3. package/assets/styles/content-base.css +33 -0
  4. package/bin/content-commands.mjs +9 -7
  5. package/docs/api.md +6 -5
  6. package/docs/authoring/links-and-markup.md +164 -16
  7. package/docs/commands.md +23 -19
  8. package/docs/configuration.md +16 -0
  9. package/docs/content-format.md +4 -2
  10. package/docs/guides/site.md +43 -0
  11. package/docs/guides/writing-in-invented-scripts.md +68 -6
  12. package/docs/reference/format-details.md +143 -49
  13. package/docs/reference/note-types.md +47 -28
  14. package/docs/source-preview.md +2 -2
  15. package/engine/content-blocks.mjs +6 -2
  16. package/engine/content-format.yaml +2 -0
  17. package/engine/content-index.mjs +5 -1
  18. package/engine/content-render.mjs +2 -1
  19. package/engine/content-scripts.mjs +268 -21
  20. package/engine/content-scroll.mjs +389 -0
  21. package/engine/font-outlines.mjs +394 -0
  22. package/engine/language-data.mjs +154 -63
  23. package/engine/language-rows.mjs +32 -6
  24. package/engine/language-terms.mjs +32 -8
  25. package/engine/map-lexicon.mjs +31 -6
  26. package/engine/note-vocabulary.mjs +58 -6
  27. package/engine/pdf-fonts.mjs +1 -1
  28. package/engine/pdf-render.mjs +52 -11
  29. package/engine/run-rows.mjs +45 -3
  30. package/engine/script-direction.mjs +67 -0
  31. package/engine/script-emphasis-terms.mjs +11 -3
  32. package/engine/script-font-delivery.mjs +42 -9
  33. package/engine/script-fonts.mjs +7 -0
  34. package/engine/site-build.mjs +8 -1
  35. package/engine/site-reach.mjs +213 -0
  36. package/engine/site-render.mjs +120 -45
  37. package/engine/site-root.mjs +68 -26
  38. package/engine/site-serve.mjs +2 -1
  39. package/engine/site-theme.mjs +19 -6
  40. package/engine/sql-tables.mjs +9 -6
  41. package/engine/verse-metre.mjs +1 -0
  42. package/engine/written-in.mjs +208 -0
  43. package/package.json +1 -1
  44. package/site-theme/static/css/style.css +7 -0
  45. package/types/engine/content-scripts.d.mts +31 -6
  46. package/types/engine/content-scroll.d.mts +179 -0
  47. package/types/engine/font-outlines.d.mts +67 -0
  48. package/types/engine/language-data.d.mts +13 -0
  49. package/types/engine/language-rows.d.mts +4 -3
  50. package/types/engine/language-terms.d.mts +7 -0
  51. package/types/engine/map-lexicon.d.mts +4 -3
  52. package/types/engine/pdf-fonts.d.mts +10 -0
  53. package/types/engine/run-rows.d.mts +28 -6
  54. package/types/engine/script-direction.d.mts +10 -0
  55. package/types/engine/script-emphasis-terms.d.mts +10 -3
  56. package/types/engine/script-font-delivery.d.mts +5 -3
  57. package/types/engine/script-fonts.d.mts +9 -0
  58. package/types/engine/site-reach.d.mts +76 -0
  59. package/types/engine/site-render.d.mts +4 -2
  60. package/types/engine/site-root.d.mts +2 -0
  61. package/types/engine/written-in.d.mts +76 -0
@@ -0,0 +1,394 @@
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 face's outlines, drawn as SVG.** The book sets a `scroll` fence as panel
16
+ * images because Typst has no vertical writing, and draws each character from
17
+ * the face's own TrueType outlines, so a panel needs no renderer and no font
18
+ * on the machine that compiles the book.
19
+ *
20
+ * - **`cmap`** (formats 4 and 12) gives each code point its glyph.
21
+ * - **`loca` and `glyf`** give each glyph's quadratic contours, a composite
22
+ * glyph's components moved and scaled into place.
23
+ * - **`hmtx`** gives a glyph's width, which centres it in its column.
24
+ * - **`vmtx`**, where the face has it, gives the space above a glyph in a
25
+ * column one em tall; a face without it is centred on its ascender and
26
+ * descender.
27
+ *
28
+ * A panel is one SVG: each glyph drawn once as a path and placed by `<use>`,
29
+ * the columns read from the right, `rows` ems tall and
30
+ * {@link module:engine/content-scroll.SCROLL_PITCH} ems from column to column.
31
+ * A character the face does not draw, and whitespace, leave their cell empty.
32
+ *
33
+ * @module
34
+ */
35
+
36
+ import fs from "node:fs";
37
+
38
+ import { SCROLL_PITCH } from "./content-scroll.mjs";
39
+ import { tableDirectory } from "./pdf-fonts.mjs";
40
+
41
+ /**
42
+ * One glyph, in font units, its y axis pointing down from the baseline.
43
+ *
44
+ * @typedef {object} Glyph
45
+ * @property {number} index - Its glyph index.
46
+ * @property {string} d - Its outline as SVG path data; `""` for none.
47
+ * @property {number} advance - Its horizontal advance.
48
+ * @property {number} top - From the top of its cell to its baseline.
49
+ */
50
+
51
+ /**
52
+ * A face's outlines.
53
+ *
54
+ * @typedef {object} Outlines
55
+ * @property {number} unitsPerEm - Its units per em.
56
+ * @property {(point: number) => Glyph|undefined} glyph - The glyph a code
57
+ * point is drawn with, or `undefined` when the face does not draw it.
58
+ */
59
+
60
+ /** @type {Map<string, Outlines>} */
61
+ const cache = new Map();
62
+
63
+ /**
64
+ * Code point → glyph index, from a `cmap` of format 4 or 12.
65
+ *
66
+ * @param {Buffer} buf - The font file.
67
+ * @param {{offset: number}} table - The `cmap` record.
68
+ * @returns {Map<number, number>} Glyph indices by code point.
69
+ */
70
+ function codepointGlyphs(buf, table) {
71
+ const out = new Map();
72
+ const at = table.offset;
73
+ const numTables = buf.readUInt16BE(at + 2);
74
+ for (let i = 0; i < numTables; i += 1) {
75
+ const sub = at + buf.readUInt32BE(at + 4 + i * 8 + 4);
76
+ const format = buf.readUInt16BE(sub);
77
+ if (format === 4) {
78
+ const segX2 = buf.readUInt16BE(sub + 6);
79
+ const endAt = sub + 14;
80
+ const startAt = endAt + segX2 + 2;
81
+ const deltaAt = startAt + segX2;
82
+ const rangeAt = deltaAt + segX2;
83
+ for (let s = 0; s < segX2 / 2; s += 1) {
84
+ const end = buf.readUInt16BE(endAt + s * 2);
85
+ const start = buf.readUInt16BE(startAt + s * 2);
86
+ const delta = buf.readInt16BE(deltaAt + s * 2);
87
+ const rangeOffset = buf.readUInt16BE(rangeAt + s * 2);
88
+ if (start === 0xffff) continue;
89
+ for (let cp = start; cp <= end; cp += 1) {
90
+ let glyph;
91
+ if (rangeOffset === 0) glyph = (cp + delta) & 0xffff;
92
+ else {
93
+ glyph = buf.readUInt16BE(rangeAt + s * 2 + rangeOffset + (cp - start) * 2);
94
+ if (glyph) glyph = (glyph + delta) & 0xffff;
95
+ }
96
+ if (glyph && !out.has(cp)) out.set(cp, glyph);
97
+ }
98
+ }
99
+ } else if (format === 12) {
100
+ const groups = buf.readUInt32BE(sub + 12);
101
+ for (let g = 0; g < groups; g += 1) {
102
+ const row = sub + 16 + g * 12;
103
+ const start = buf.readUInt32BE(row);
104
+ const end = Math.min(buf.readUInt32BE(row + 4), start + 0x1_0000);
105
+ const first = buf.readUInt32BE(row + 8);
106
+ for (let cp = start; cp <= end; cp += 1)
107
+ if (!out.has(cp)) out.set(cp, first + (cp - start));
108
+ }
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * A point of a contour.
116
+ *
117
+ * @typedef {{x: number, y: number, on: boolean}} Point
118
+ */
119
+
120
+ /**
121
+ * A glyph's contours, its components resolved.
122
+ *
123
+ * @param {Buffer} buf - The font file.
124
+ * @param {number} glyf - The `glyf` table's offset.
125
+ * @param {(index: number) => [number, number]} span - A glyph's offset in
126
+ * `glyf` and the offset past it.
127
+ * @param {number} index - The glyph.
128
+ * @param {number} depth - How deep in a composite this glyph sits.
129
+ * @returns {Point[][]} Its contours.
130
+ */
131
+ function contours(buf, glyf, span, index, depth = 0) {
132
+ const [from, to] = span(index);
133
+ if (to <= from || depth > 8) return [];
134
+ const at = glyf + from;
135
+ const count = buf.readInt16BE(at);
136
+ if (count >= 0) {
137
+ const ends = [];
138
+ for (let c = 0; c < count; c += 1) ends.push(buf.readUInt16BE(at + 10 + c * 2));
139
+ const points = count ? ends[count - 1] + 1 : 0;
140
+ let p = at + 10 + count * 2;
141
+ p += 2 + buf.readUInt16BE(p);
142
+ const flags = [];
143
+ while (flags.length < points) {
144
+ const flag = buf.readUInt8(p++);
145
+ flags.push(flag);
146
+ if (flag & 8) {
147
+ const repeat = buf.readUInt8(p++);
148
+ for (let r = 0; r < repeat; r += 1) flags.push(flag);
149
+ }
150
+ }
151
+ const read = (short, same) => {
152
+ const values = [];
153
+ let value = 0;
154
+ for (const flag of flags) {
155
+ if (flag & short) {
156
+ const delta = buf.readUInt8(p++);
157
+ value += flag & same ? delta : -delta;
158
+ } else if (!(flag & same)) {
159
+ value += buf.readInt16BE(p);
160
+ p += 2;
161
+ }
162
+ values.push(value);
163
+ }
164
+ return values;
165
+ };
166
+ const xs = read(2, 16);
167
+ const ys = read(4, 32);
168
+ const out = [];
169
+ let start = 0;
170
+ for (const end of ends) {
171
+ const contour = [];
172
+ for (let k = start; k <= end; k += 1)
173
+ contour.push({ x: xs[k], y: ys[k], on: Boolean(flags[k] & 1) });
174
+ out.push(contour);
175
+ start = end + 1;
176
+ }
177
+ return out;
178
+ }
179
+ const out = [];
180
+ let p = at + 10;
181
+ for (;;) {
182
+ const flags = buf.readUInt16BE(p);
183
+ const component = buf.readUInt16BE(p + 2);
184
+ p += 4;
185
+ let dx;
186
+ let dy;
187
+ if (flags & 1) {
188
+ dx = buf.readInt16BE(p);
189
+ dy = buf.readInt16BE(p + 2);
190
+ p += 4;
191
+ } else {
192
+ dx = buf.readInt8(p);
193
+ dy = buf.readInt8(p + 1);
194
+ p += 2;
195
+ }
196
+ if (!(flags & 2)) {
197
+ dx = 0;
198
+ dy = 0;
199
+ }
200
+ let [a, b, c, d] = [1, 0, 0, 1];
201
+ const f2dot14 = (offset) => buf.readInt16BE(offset) / 16384;
202
+ if (flags & 8) {
203
+ a = d = f2dot14(p);
204
+ p += 2;
205
+ } else if (flags & 0x40) {
206
+ a = f2dot14(p);
207
+ d = f2dot14(p + 2);
208
+ p += 4;
209
+ } else if (flags & 0x80) {
210
+ a = f2dot14(p);
211
+ b = f2dot14(p + 2);
212
+ c = f2dot14(p + 4);
213
+ d = f2dot14(p + 6);
214
+ p += 8;
215
+ }
216
+ for (const contour of contours(buf, glyf, span, component, depth + 1))
217
+ out.push(
218
+ contour.map(({ x, y, on }) => ({
219
+ x: a * x + c * y + dx,
220
+ y: b * x + d * y + dy,
221
+ on,
222
+ })),
223
+ );
224
+ if (!(flags & 0x20)) break;
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /** A number as path data writes it. */
230
+ const num = (value) => String(Math.round(value * 100) / 100);
231
+
232
+ /**
233
+ * A contour of quadratic segments as SVG path data, its y axis flipped so it
234
+ * points down from the baseline.
235
+ *
236
+ * @param {Point[]} contour - The contour.
237
+ * @returns {string} The path data.
238
+ */
239
+ function contourPath(contour) {
240
+ if (!contour.length) return "";
241
+ const points = contour.map(({ x, y, on }) => ({ x, y: -y, on }));
242
+ const mid = (p, q) => ({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2, on: true });
243
+ let first = points.findIndex((point) => point.on);
244
+ let start;
245
+ let ordered;
246
+ if (first < 0) {
247
+ start = mid(points[points.length - 1], points[0]);
248
+ ordered = points;
249
+ } else {
250
+ start = points[first];
251
+ ordered = [...points.slice(first + 1), ...points.slice(0, first)];
252
+ }
253
+ let d = `M${num(start.x)} ${num(start.y)}`;
254
+ let control;
255
+ for (const point of [...ordered, start]) {
256
+ if (point.on) {
257
+ d +=
258
+ control ?
259
+ `Q${num(control.x)} ${num(control.y)} ${num(point.x)} ${num(point.y)}`
260
+ : `L${num(point.x)} ${num(point.y)}`;
261
+ control = undefined;
262
+ } else if (control) {
263
+ const between = mid(control, point);
264
+ d += `Q${num(control.x)} ${num(control.y)} ${num(between.x)} ${num(between.y)}`;
265
+ control = point;
266
+ } else control = point;
267
+ }
268
+ return `${d}Z`;
269
+ }
270
+
271
+ /**
272
+ * The outlines of a TrueType face, read once per file.
273
+ *
274
+ * @param {string} file - The `.ttf`.
275
+ * @returns {Outlines} Its outlines.
276
+ * @throws {Error} When the file is not a TrueType face with outlines.
277
+ */
278
+ export function fontOutlines(file) {
279
+ const known = cache.get(file);
280
+ if (known) return known;
281
+ const buf = fs.readFileSync(file);
282
+ const tables = tableDirectory(buf);
283
+ for (const tag of ["head", "maxp", "hhea", "hmtx", "loca", "glyf", "cmap"])
284
+ if (!tables.has(tag))
285
+ throw new Error(
286
+ `${file}: error: the face has no \`${tag}\` table to draw a scroll from`,
287
+ );
288
+ const head = /** @type {{offset: number}} */ (tables.get("head")).offset;
289
+ const unitsPerEm = buf.readUInt16BE(head + 18);
290
+ const longOffsets = buf.readInt16BE(head + 50) === 1;
291
+ const numGlyphs = buf.readUInt16BE(
292
+ /** @type {{offset: number}} */ (tables.get("maxp")).offset + 4,
293
+ );
294
+ const hhea = /** @type {{offset: number}} */ (tables.get("hhea")).offset;
295
+ const ascender = buf.readInt16BE(hhea + 4);
296
+ const descender = buf.readInt16BE(hhea + 6);
297
+ const hMetrics = buf.readUInt16BE(hhea + 34);
298
+ const hmtx = /** @type {{offset: number}} */ (tables.get("hmtx")).offset;
299
+ const loca = /** @type {{offset: number}} */ (tables.get("loca")).offset;
300
+ const glyf = /** @type {{offset: number}} */ (tables.get("glyf")).offset;
301
+ const vhea = tables.get("vhea");
302
+ const vmtx = tables.get("vmtx");
303
+ const vMetrics = vhea && vmtx ? buf.readUInt16BE(vhea.offset + 34) : 0;
304
+ const span = (index) =>
305
+ index >= numGlyphs ? [0, 0]
306
+ : longOffsets ? [buf.readUInt32BE(loca + index * 4), buf.readUInt32BE(loca + index * 4 + 4)]
307
+ : [buf.readUInt16BE(loca + index * 2) * 2, buf.readUInt16BE(loca + index * 2 + 2) * 2];
308
+ const glyphs = codepointGlyphs(buf, /** @type {{offset: number}} */ (tables.get("cmap")));
309
+ /** @type {Map<number, Glyph>} */
310
+ const drawn = new Map();
311
+ const glyphOf = (index) => {
312
+ const had = drawn.get(index);
313
+ if (had) return had;
314
+ const advance = buf.readUInt16BE(hmtx + Math.min(index, hMetrics - 1) * 4);
315
+ const [from, to] = span(index);
316
+ const yMax = to > from ? buf.readInt16BE(glyf + from + 8) : 0;
317
+ let top;
318
+ if (vmtx && vMetrics) {
319
+ const tsb =
320
+ index < vMetrics ?
321
+ buf.readInt16BE(vmtx.offset + index * 4 + 2)
322
+ : buf.readInt16BE(vmtx.offset + vMetrics * 4 + (index - vMetrics) * 2);
323
+ top = yMax + tsb;
324
+ } else top = ascender + (unitsPerEm - (ascender - descender)) / 2;
325
+ const glyph = {
326
+ index,
327
+ d: contours(buf, glyf, span, index).map(contourPath).join(""),
328
+ advance,
329
+ top,
330
+ };
331
+ drawn.set(index, glyph);
332
+ return glyph;
333
+ };
334
+ const outlines = {
335
+ unitsPerEm,
336
+ glyph: (point) => {
337
+ const index = glyphs.get(point);
338
+ return index === undefined ? undefined : glyphOf(index);
339
+ },
340
+ };
341
+ cache.set(file, outlines);
342
+ return outlines;
343
+ }
344
+
345
+ /** The book's rubric red, which its preamble names `book-rubric`. */
346
+ export const BOOK_RUBRIC = "#9c281b";
347
+
348
+ /** The book's ink, which its preamble names `book-ink`. */
349
+ export const BOOK_INK = "#241f1a";
350
+
351
+ /** The colour of plain text and of text set in the rubric. */
352
+ const INK = Object.freeze({ plain: BOOK_INK, rubric: BOOK_RUBRIC });
353
+
354
+ /**
355
+ * One panel of a scroll as SVG: its columns read from the right, `rows` ems
356
+ * tall, each character centred in its column, in red where it is set in the
357
+ * rubric and with a line beside it, on the right, where it is overlined.
358
+ *
359
+ * @param {import("./content-scroll.mjs").ScrollCell[][]} panel - Its columns,
360
+ * the first the rightmost.
361
+ * @param {number} rows - The characters in a column.
362
+ * @param {Outlines} outlines - The face.
363
+ * @returns {string} The SVG.
364
+ */
365
+ export function scrollPanelSvg(panel, rows, outlines) {
366
+ const em = outlines.unitsPerEm;
367
+ const pitch = SCROLL_PITCH * em;
368
+ const width = panel.length * pitch;
369
+ const height = rows * em;
370
+ const defs = new Map();
371
+ const marks = [];
372
+ panel.forEach((column, at) => {
373
+ const centre = width - (at + 0.5) * pitch;
374
+ column.forEach((cell, row) => {
375
+ const cellTop = row * em;
376
+ if (cell.emphasis === "overline")
377
+ marks.push(
378
+ `<rect x="${num(centre + 0.55 * em)}" y="${num(cellTop)}" width="${num(0.05 * em)}" height="${num(em)}" fill="${INK.plain}"/>`,
379
+ );
380
+ const point = cell.text.codePointAt(0);
381
+ if (point === undefined || /\s/u.test(cell.text)) return;
382
+ const glyph = outlines.glyph(point);
383
+ if (!glyph?.d) return;
384
+ defs.set(glyph.index, `<path id="g${glyph.index}" d="${glyph.d}"/>`);
385
+ marks.push(
386
+ `<use href="#g${glyph.index}" x="${num(centre - glyph.advance / 2)}" y="${num(cellTop + glyph.top)}" fill="${cell.emphasis === "rubric" ? INK.rubric : INK.plain}"/>`,
387
+ );
388
+ });
389
+ });
390
+ return (
391
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${num(width)} ${num(height)}" width="${num(width)}" height="${num(height)}">` +
392
+ `<defs>${[...defs.values()].join("")}</defs>${marks.join("")}</svg>`
393
+ );
394
+ }
@@ -21,9 +21,11 @@
21
21
  * - no skill of another subType states these keys;
22
22
  * - a tongue tag is lowercase letters and digits, optionally closing on
23
23
  * `:<tongue>`, and stands once; a tongue's `language` names a language note;
24
- * - every tag a register row or a letter row names is declared in
25
- * `data.tongues`, `translation` apart: `exonym:vylari` is declared by
26
- * `exonym:<tongue>`;
24
+ * - every tag a register row names is declared in `data.tongues`,
25
+ * `translation` apart: `exonym:vylari` is declared by `exonym:<tongue>`;
26
+ * - every tongue a letter row names is such a tag or the Address of a
27
+ * language note, and a spelling guide's, on a `doc` note, an Address (see
28
+ * {@link checkGuideSounds});
27
29
  * - an edge stands once in `data.sounds.edges`;
28
30
  * - a sound stands once in each mutation of `data.sounds.mutations`;
29
31
  * - a cluster stands in one group of `data.sounds.clusters.groups`, and a
@@ -432,6 +434,153 @@ function checkParticiples(participles, affixes, finding) {
432
434
  return out;
433
435
  }
434
436
 
437
+ /**
438
+ * The Address checks a note's data reads through, each finding located by
439
+ * `finding`.
440
+ *
441
+ * @param {object|undefined} index - The index; without `addressHit`, nothing
442
+ * resolves and nothing is reported.
443
+ * @param {(path: Array<string|number>, severity: string, message: string, key?: boolean) => object} finding -
444
+ * A finding at a path of the note's frontmatter.
445
+ * @returns {{resolve: Function, language: Function}} The checks.
446
+ */
447
+ function resolvers(index, finding) {
448
+ /**
449
+ * The note an Address names, or a finding where it names none.
450
+ *
451
+ * @param {unknown} value - The Address as the note holds it.
452
+ * @param {Array<string|number>} path - Where it sits.
453
+ * @param {string|undefined} type - The type a bare shortcode defaults to.
454
+ * @param {boolean} [key] - Whether the Address is a map key.
455
+ * @returns {{target?: object, key?: string, finding?: object}}
456
+ */
457
+ function resolve(value, path, type, key = false) {
458
+ if (!index?.addressHit || value === undefined || value === null) return {};
459
+ const canonical = addressKeyOf(value, index, type);
460
+ if (!canonical)
461
+ return {
462
+ finding: finding(
463
+ path,
464
+ "error",
465
+ `\`${label(path)}\` is not an Address: ${JSON.stringify(
466
+ isAddressTuple(value) ? renderAddress(value) : value,
467
+ )}`,
468
+ key,
469
+ ),
470
+ };
471
+ const target = index.addressHit(canonical);
472
+ if (!target)
473
+ return {
474
+ finding: finding(
475
+ path,
476
+ "error",
477
+ `\`${label(path)}\` names ${canonical}, which does not resolve in this ` +
478
+ "package or its declared dependencies",
479
+ key,
480
+ ),
481
+ };
482
+ return { target, key: canonical };
483
+ }
484
+
485
+ /** As {@link resolve}, and the target must be a language note. */
486
+ function language(value, path, key = false) {
487
+ const result = resolve(value, path, "skill", key);
488
+ if (result.finding) return [result.finding];
489
+ if (!result.target) return [];
490
+ const target = result.target.fm ?? result.target;
491
+ if (target.type === "skill" && target.subType === LANGUAGE_SUBTYPE) return [];
492
+ return [
493
+ finding(
494
+ path,
495
+ "error",
496
+ `\`${label(path)}\` names ${result.key}, which is not a skill whose subType is ` +
497
+ "language",
498
+ key,
499
+ ),
500
+ ];
501
+ }
502
+
503
+ return { resolve, language };
504
+ }
505
+
506
+ /**
507
+ * The findings on each `tongue` of a note's `data.sounds.letters` rows. A value
508
+ * is a tag the note's `data.tongues` declares, or else the Address of a
509
+ * language note; a `doc` note declares no tags, so its rows name language
510
+ * notes only. Without an index no Address resolves: a language note's value
511
+ * that is not a declared tag is then a finding, and a guide's is not checked.
512
+ *
513
+ * @param {unknown} letters - The rows.
514
+ * @param {object} context
515
+ * @param {Set<string>|undefined} context.declared - The tags `data.tongues`
516
+ * declares; `undefined` on a note that declares none.
517
+ * @param {object|undefined} context.index - The index Addresses resolve in.
518
+ * @param {(path: Array<string|number>, severity: string, message: string, key?: boolean) => object} context.finding -
519
+ * A finding at a path of the note's frontmatter.
520
+ * @returns {object[]} The findings, each at its value.
521
+ */
522
+ function letterTongueFindings(letters, { declared, index, finding }) {
523
+ const { resolve, language } = resolvers(index, finding);
524
+ const out = [];
525
+ listOf(letters).forEach((row, i) => {
526
+ if (!isMap(row) || row.tongue === undefined || row.tongue === null) return;
527
+ const at = ["data", "sounds", "letters", i, "tongue"];
528
+ const values = Array.isArray(row.tongue) ? row.tongue : [row.tongue];
529
+ values.forEach((value, j) => {
530
+ if (typeof value !== "string") return;
531
+ if (declared && tongueDeclared(value, declared)) return;
532
+ const path = Array.isArray(row.tongue) ? [...at, j] : at;
533
+ const resolved = resolve(value, path, "skill");
534
+ if (resolved.target) {
535
+ out.push(...language(value, path));
536
+ return;
537
+ }
538
+ if (declared)
539
+ out.push(
540
+ finding(
541
+ path,
542
+ "error",
543
+ `the tongue \`${value}\` is not declared in \`data.tongues\`` +
544
+ (index?.addressHit ? " and names no language note" : ""),
545
+ ),
546
+ );
547
+ else if (resolved.finding)
548
+ out.push(
549
+ finding(
550
+ path,
551
+ "error",
552
+ `\`${label(path)}\` names \`${value}\`, which is no language note's ` +
553
+ "Address; a spelling guide names each tongue by its language note",
554
+ ),
555
+ );
556
+ });
557
+ });
558
+ return out;
559
+ }
560
+
561
+ /**
562
+ * Check a `doc` note's `data.sounds`, a spelling guide's letters and marks:
563
+ * each letter row's `tongue` names language notes. The data-key check owns
564
+ * the keys, so a sound rule a guide does not state is refused there.
565
+ *
566
+ * @param {object} note - A `doc` note: `file`, `raw` and parsed `fm`.
567
+ * @param {object} [context]
568
+ * @param {object} [context.index] - The index, for whether an Address resolves.
569
+ * @returns {object[]} The findings.
570
+ */
571
+ export function checkGuideSounds(note, { index } = {}) {
572
+ const sounds = note?.fm?.data?.sounds;
573
+ if (!isMap(sounds)) return [];
574
+ const raw = note.raw ?? "";
575
+ const finding = (path, severity, message, key = false) => ({
576
+ file: note.file,
577
+ ...positionOfFrontmatterPath(raw, path, { key }),
578
+ severity,
579
+ message,
580
+ });
581
+ return letterTongueFindings(sounds.letters, { declared: undefined, index, finding });
582
+ }
583
+
435
584
  /**
436
585
  * Check a `skill` note's language data against what it means.
437
586
  *
@@ -542,60 +691,7 @@ export function checkLanguageData(note, { index, types } = {}) {
542
691
  ...checkAffixClasses(data.affixes, finding),
543
692
  );
544
693
 
545
- /**
546
- * The note an Address names, or a finding where it names none.
547
- *
548
- * @param {unknown} value - The Address as the note holds it.
549
- * @param {Array<string|number>} path - Where it sits.
550
- * @param {string|undefined} type - The type a bare shortcode defaults to.
551
- * @param {boolean} [key] - Whether the Address is a map key.
552
- * @returns {{target?: object, key?: string, finding?: object}}
553
- */
554
- function resolve(value, path, type, key = false) {
555
- if (!index?.addressHit || value === undefined || value === null) return {};
556
- const canonical = addressKeyOf(value, index, type);
557
- if (!canonical)
558
- return {
559
- finding: finding(
560
- path,
561
- "error",
562
- `\`${label(path)}\` is not an Address: ${JSON.stringify(
563
- isAddressTuple(value) ? renderAddress(value) : value,
564
- )}`,
565
- key,
566
- ),
567
- };
568
- const target = index.addressHit(canonical);
569
- if (!target)
570
- return {
571
- finding: finding(
572
- path,
573
- "error",
574
- `\`${label(path)}\` names ${canonical}, which does not resolve in this ` +
575
- "package or its declared dependencies",
576
- key,
577
- ),
578
- };
579
- return { target, key: canonical };
580
- }
581
-
582
- /** As {@link resolve}, and the target must be a language note. */
583
- function language(value, path, key = false) {
584
- const result = resolve(value, path, "skill", key);
585
- if (result.finding) return [result.finding];
586
- if (!result.target) return [];
587
- const target = result.target.fm ?? result.target;
588
- if (target.type === "skill" && target.subType === LANGUAGE_SUBTYPE) return [];
589
- return [
590
- finding(
591
- path,
592
- "error",
593
- `\`${label(path)}\` names ${result.key}, which is not a skill whose subType is ` +
594
- "language",
595
- key,
596
- ),
597
- ];
598
- }
694
+ const { resolve, language } = resolvers(index, finding);
599
695
 
600
696
  // ── Tongues ──────────────────────────────────────────────────────────────
601
697
  const declared = new Set();
@@ -668,12 +764,7 @@ export function checkLanguageData(note, { index, types } = {}) {
668
764
 
669
765
  // ── Sounds ───────────────────────────────────────────────────────────────
670
766
  const sounds = isMap(data.sounds) ? data.sounds : {};
671
- listOf(sounds.letters).forEach((row, i) => {
672
- if (!isMap(row) || row.tongue === undefined || row.tongue === null) return;
673
- const path = ["data", "sounds", "letters", i, "tongue"];
674
- if (Array.isArray(row.tongue)) undeclared(row.tongue, (j) => [...path, j]);
675
- else undeclared([row.tongue], () => path);
676
- });
767
+ findings.push(...letterTongueFindings(sounds.letters, { declared, index, finding }));
677
768
  const edgeAt = new Map();
678
769
  listOf(sounds.edges).forEach((row, i) => {
679
770
  if (!isMap(row) || typeof row.edge !== "string") return;