@heroiclands/package-build 20.4.0 → 20.6.0

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 (78) hide show
  1. package/CHANGELOG.md +288 -0
  2. package/CONTENT.md +213 -20
  3. package/README.md +19 -1
  4. package/bin/content-build.mjs +135 -32
  5. package/bin/package-build.mjs +46 -13
  6. package/content-config.mjs +345 -101
  7. package/docs/api.md +1352 -0
  8. package/docs/commands.md +1609 -0
  9. package/docs/configuration.md +1432 -0
  10. package/docs/content-format.md +16 -6
  11. package/docs/diagnostics.md +356 -0
  12. package/docs/getting-started.md +813 -0
  13. package/docs/project-setup.md +469 -0
  14. package/engine/actor-compiler.mjs +30 -27
  15. package/engine/address-diff.mjs +45 -41
  16. package/engine/base-compiler.mjs +6 -0
  17. package/engine/bundles.mjs +9 -0
  18. package/engine/content-address.mjs +9 -9
  19. package/engine/content-index.mjs +44 -23
  20. package/engine/content-links.mjs +44 -11
  21. package/engine/content-lint.mjs +44 -10
  22. package/engine/content-tables.mjs +32 -27
  23. package/engine/folder-notes.mjs +4 -2
  24. package/engine/frontmatter-lint.mjs +35 -38
  25. package/engine/generate.mjs +5 -0
  26. package/engine/helpers.mjs +86 -32
  27. package/engine/index.mjs +12 -2
  28. package/engine/journals.mjs +9 -0
  29. package/engine/note-claims.mjs +18 -10
  30. package/engine/note-schemas.mjs +0 -5
  31. package/engine/note-vocabulary.mjs +32 -31
  32. package/engine/pack-config.mjs +26 -12
  33. package/engine/pack-router.mjs +0 -0
  34. package/engine/pdf-build.mjs +464 -0
  35. package/engine/pdf-fonts.mjs +420 -0
  36. package/engine/pdf-render.mjs +876 -0
  37. package/engine/pdf-toc.mjs +525 -0
  38. package/engine/scenes.mjs +14 -5
  39. package/engine/schema-check.mjs +1 -1
  40. package/engine/site-build.mjs +21 -3
  41. package/engine/web-wikilinks.mjs +6 -3
  42. package/engine/wikilinks.mjs +2 -4
  43. package/hm3/actors.mjs +8 -0
  44. package/hm3/items.mjs +8 -0
  45. package/package.json +1 -1
  46. package/release.mjs +63 -3
  47. package/sohl/actors.mjs +8 -0
  48. package/sohl/items.mjs +8 -0
  49. package/sohl/note-schemas.mjs +5 -5
  50. package/types/content-config.d.mts +66 -15
  51. package/types/engine/actor-compiler.d.mts +34 -30
  52. package/types/engine/address-diff.d.mts +57 -3
  53. package/types/engine/base-compiler.d.mts +10 -2
  54. package/types/engine/bundles.d.mts +9 -0
  55. package/types/engine/content-address.d.mts +9 -9
  56. package/types/engine/content-index.d.mts +57 -13
  57. package/types/engine/content-lint.d.mts +6 -4
  58. package/types/engine/content-tables.d.mts +49 -18
  59. package/types/engine/frontmatter-lint.d.mts +3 -2
  60. package/types/engine/helpers.d.mts +105 -31
  61. package/types/engine/index.d.mts +4 -0
  62. package/types/engine/journals.d.mts +9 -0
  63. package/types/engine/note-claims.d.mts +17 -10
  64. package/types/engine/note-vocabulary.d.mts +23 -196
  65. package/types/engine/pack-config.d.mts +4 -4
  66. package/types/engine/pdf-build.d.mts +42 -0
  67. package/types/engine/pdf-fonts.d.mts +30 -0
  68. package/types/engine/pdf-render.d.mts +156 -0
  69. package/types/engine/pdf-toc.d.mts +114 -0
  70. package/types/engine/scenes.d.mts +10 -1
  71. package/types/engine/schema-check.d.mts +2 -2
  72. package/types/engine/site-build.d.mts +34 -6
  73. package/types/engine/wikilinks.d.mts +2 -3
  74. package/types/hm3/actors.d.mts +8 -0
  75. package/types/hm3/items.d.mts +8 -0
  76. package/types/release.d.mts +15 -4
  77. package/types/sohl/actors.d.mts +10 -2
  78. package/types/sohl/items.d.mts +8 -0
@@ -0,0 +1,525 @@
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
+ * The document tree a PDF is built from, and the plan it resolves to (#316).
16
+ *
17
+ * The packs and the website both render the *whole* content tree: every note
18
+ * becomes a document and a page, and the three surfaces agreeing about what the
19
+ * content is, is the point. **A book is not that.** It is a selection — a
20
+ * declared structure whose leaves pick notes out of the corpus by a `WHERE`
21
+ * clause, interleaved with prose that may not live in the content tree at all.
22
+ *
23
+ * Three consequences follow, and they are behaviour rather than oversights:
24
+ *
25
+ * - **A note no clause selects is not in the book.** A project decides what its
26
+ * own volume carries, so an omission is an editorial act and this pass has no
27
+ * standing to call it a defect.
28
+ * - **A note several clauses select appears several times.** A reference work
29
+ * may legitimately carry an entry under more than one heading, so each
30
+ * occurrence is its own page, its own outline node and its own anchor — and
31
+ * inbound links are pointed at the first, so a wikilink resolves to one place
32
+ * however often the book prints it.
33
+ * - **Prose from `file:` is in the book but not of the tree.** It carries no
34
+ * address, so nothing can link *to* it, but it is anchored for the table of
35
+ * contents and its own links still resolve.
36
+ *
37
+ * **What is here is pure.** The tree is validated, flattened and planned without
38
+ * reading a file or opening a database: the filters are run by the caller and
39
+ * handed back as `selections`, the way `expandContentTables` is handed its
40
+ * `sqlTables` because DuckDB is async and the pass is not. That is what lets the
41
+ * whole structure of a 2,500-entry book be asserted in a unit test with no
42
+ * renderer present, which is most of what #316 asks for.
43
+ *
44
+ * **Every gate reports; none exits.** A filter that will not parse, a `file:`
45
+ * that resolves nowhere and a filter reaching another package's schema are
46
+ * findings the command decides about, as everywhere else in this engine.
47
+ *
48
+ * @module
49
+ */
50
+
51
+ import { slugify } from "./content-slug.mjs";
52
+ import { positionOfYamlPath } from "./diagnostics.mjs";
53
+
54
+ /**
55
+ * Presentation a node may declare, and that its descendants inherit.
56
+ *
57
+ * Reserved now although the first release renders none of them, because the
58
+ * shape of the file is the thing consumers commit to: a book that has to be
59
+ * restructured to gain a running head has the wrong format, not the wrong
60
+ * renderer. Inheritance is what makes them worth declaring at all — `Gear` says
61
+ * once which infobox its entries use, and nine sections beneath it agree.
62
+ *
63
+ * @type {readonly string[]}
64
+ */
65
+ export const PRESENTATION_KEYS = Object.freeze(["header", "footer", "infobox", "page"]);
66
+
67
+ /** Keys a section node may carry. @type {readonly string[]} */
68
+ const SECTION_KEYS = Object.freeze(["sectionName", "contents", ...PRESENTATION_KEYS]);
69
+
70
+ /** Keys a content entry may carry instead of being a section. */
71
+ const LEAF_KEYS = Object.freeze(["file", "filter"]);
72
+
73
+ /**
74
+ * A qualified table name — `sohl.notes` rather than `notes`.
75
+ *
76
+ * The build owns the `SELECT … FROM notes`, so a filter cannot name a table at
77
+ * all; the one way back out of that is a subquery in the `WHERE` clause, which
78
+ * would reach a dependency's schema and put another package's notes in this
79
+ * package's book. Matched loosely on purpose: this refuses a shape rather than
80
+ * parsing SQL, and a false positive is a filter that has to be rewritten while a
81
+ * false negative is a book quietly containing someone else's content.
82
+ */
83
+ const QUALIFIED_TABLE = /\bfrom\s+["'`]?[A-Za-z_][\w-]*["'`]?\s*\./i;
84
+
85
+ /**
86
+ * Validate the raw tree and flatten it into nodes.
87
+ *
88
+ * The format has no chapter/section distinction: every node is a section, and
89
+ * the leaves of the *document* are the notes and prose its contents resolve to.
90
+ * Depth is whatever the tree says, so nothing here caps or normalises it.
91
+ *
92
+ * `contents` is an **ordered, heterogeneous** list — prose, filters and child
93
+ * sections interleave in the sequence written, and that sequence is preserved
94
+ * exactly. A section that opens with a `file:` and then lists its entries is
95
+ * saying something different from one that does the reverse.
96
+ *
97
+ * @param {unknown} raw - The parsed document, as YAML returns it.
98
+ * @param {object} [opts] - Options.
99
+ * @param {string} [opts.text] - The file's source, for finding positions. Every
100
+ * finding without one is still reported, just without a locator.
101
+ * @returns {{nodes: object[], findings: object[]}} The flattened tree and what
102
+ * was wrong with it.
103
+ */
104
+ export function parseDocumentTree(raw, { text } = {}) {
105
+ const findings = [];
106
+ const nodes = [];
107
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.contents)) {
108
+ findings.push({
109
+ severity: "error",
110
+ message: "the document tree must be a mapping with a `contents:` list",
111
+ });
112
+ return { nodes, findings };
113
+ }
114
+ walkSections(raw.contents, ["contents"], [], {}, { nodes, findings, text });
115
+ return { nodes, findings };
116
+ }
117
+
118
+ /**
119
+ * One level of `contents`, recursing into the sections it holds.
120
+ *
121
+ * Presentation is resolved on the way down rather than looked up on the way
122
+ * back: a node's own keys override what it inherits, and its children see the
123
+ * merged result, so nothing downstream has to walk back up an ancestry chain to
124
+ * learn which infobox an entry uses.
125
+ *
126
+ * @param {unknown[]} contents - The list to read.
127
+ * @param {Array<string|number>} keyPath - Where it sits in the document.
128
+ * @param {string[]} trail - Section titles above this level.
129
+ * @param {object} inherited - Presentation from ancestors.
130
+ * @param {object} ctx - `{ nodes, findings, text }`, accumulated.
131
+ * @returns {void}
132
+ */
133
+ function walkSections(contents, keyPath, trail, inherited, ctx) {
134
+ contents.forEach((entry, i) => {
135
+ const at = [...keyPath, i];
136
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
137
+ ctx.findings.push(finding(ctx, at, "each entry in `contents:` must be a mapping"));
138
+ return;
139
+ }
140
+ if (!("sectionName" in entry)) {
141
+ // A bare `file:` or `filter:` at this level belongs to the enclosing
142
+ // section and is collected there, not here.
143
+ if (!LEAF_KEYS.some((k) => k in entry)) {
144
+ ctx.findings.push(
145
+ finding(ctx, at, "expected `sectionName:`, `file:` or `filter:`"),
146
+ );
147
+ }
148
+ return;
149
+ }
150
+ const title = entry.sectionName;
151
+ if (typeof title !== "string" || !title.trim()) {
152
+ ctx.findings.push(
153
+ finding(ctx, [...at, "sectionName"], "`sectionName:` must be a name"),
154
+ );
155
+ return;
156
+ }
157
+ for (const key of Object.keys(entry)) {
158
+ if (!SECTION_KEYS.includes(key)) {
159
+ ctx.findings.push(
160
+ finding(ctx, [...at, key], `unknown key \`${key}:\` on a section`, {
161
+ key: true,
162
+ }),
163
+ );
164
+ }
165
+ }
166
+
167
+ const presentation = { ...inherited };
168
+ for (const key of PRESENTATION_KEYS) {
169
+ if (entry[key] !== undefined) presentation[key] = entry[key];
170
+ }
171
+
172
+ const here = [...trail, title];
173
+ const list = Array.isArray(entry.contents) ? entry.contents : [];
174
+ if (entry.contents !== undefined && !Array.isArray(entry.contents)) {
175
+ ctx.findings.push(
176
+ finding(ctx, [...at, "contents"], "`contents:` must be a list", { key: true }),
177
+ );
178
+ }
179
+
180
+ ctx.nodes.push({
181
+ title,
182
+ trail: here,
183
+ depth: here.length,
184
+ keyPath: at,
185
+ presentation,
186
+ items: collectItems(list, [...at, "contents"], here, ctx),
187
+ });
188
+
189
+ walkSections(list, [...at, "contents"], here, presentation, ctx);
190
+ });
191
+ }
192
+
193
+ /**
194
+ * A section's own leaves, in the order written.
195
+ *
196
+ * Child sections are *not* included: they are walked separately so the flattened
197
+ * node list stays in document order, and a section's items are only the prose
198
+ * and the filters that belong to it directly.
199
+ *
200
+ * @param {unknown[]} contents - The section's `contents`.
201
+ * @param {Array<string|number>} keyPath - Where that list sits.
202
+ * @param {string[]} trail - The section's titles, for a stable item id.
203
+ * @param {object} ctx - `{ findings, text }`.
204
+ * @returns {object[]} Items, tagged by kind.
205
+ */
206
+ function collectItems(contents, keyPath, trail, ctx) {
207
+ const items = [];
208
+ contents.forEach((entry, i) => {
209
+ if (!entry || typeof entry !== "object" || "sectionName" in entry) return;
210
+ const at = [...keyPath, i];
211
+ if (typeof entry.file === "string" && entry.file.trim()) {
212
+ items.push({ kind: "prose", file: entry.file.trim(), keyPath: at, id: id(trail, i) });
213
+ return;
214
+ }
215
+ if (typeof entry.filter === "string" && entry.filter.trim()) {
216
+ const where = entry.filter.trim();
217
+ if (QUALIFIED_TABLE.test(where)) {
218
+ ctx.findings.push(
219
+ finding(
220
+ ctx,
221
+ [...at, "filter"],
222
+ "a filter may not name another package's notes — a book " +
223
+ "selects from its own project only",
224
+ ),
225
+ );
226
+ return;
227
+ }
228
+ items.push({ kind: "filter", where, keyPath: at, id: id(trail, i) });
229
+ return;
230
+ }
231
+ if ("file" in entry || "filter" in entry) {
232
+ ctx.findings.push(finding(ctx, at, "`file:` and `filter:` must be non-empty strings"));
233
+ }
234
+ });
235
+ return items;
236
+ }
237
+
238
+ /**
239
+ * A stable identity for one item, so selections can be keyed without the tree.
240
+ *
241
+ * The key is opaque — it is a `Map` key and nothing ever takes it apart again —
242
+ * so the only requirement is that two different items cannot produce the same
243
+ * string. `JSON.stringify` of the trail and the index gives that for free: it
244
+ * quotes and escapes each title, so a title containing the separator cannot
245
+ * forge a neighbour's key.
246
+ *
247
+ * Joining on a delimiter would work too, but every delimiter safe enough to be
248
+ * collision-proof is a control character, and a control character written into
249
+ * source is a character nobody can see in a diff, a test failure or a review.
250
+ * A JSON array is printable, greppable, and reads as itself when a plan is
251
+ * dumped.
252
+ *
253
+ * @param {string[]} trail - The owning section's titles.
254
+ * @param {number} index - The item's place in `contents`.
255
+ * @returns {string} The key.
256
+ */
257
+ function id(trail, index) {
258
+ return JSON.stringify([...trail, index]);
259
+ }
260
+
261
+ /**
262
+ * A finding, positioned in the source when the source was supplied.
263
+ *
264
+ * Carries no `file`: only the caller knows which document this was, which is the
265
+ * rule every rule in this engine follows.
266
+ *
267
+ * @param {object} ctx - `{ text }`.
268
+ * @param {Array<string|number>} keyPath - Where the problem is.
269
+ * @param {string} message - What is wrong.
270
+ * @param {object} [opts] - Passed to {@link positionOfYamlPath}.
271
+ * @returns {object} The finding.
272
+ */
273
+ function finding(ctx, keyPath, message, opts) {
274
+ return { severity: "error", message, ...position(ctx.text, keyPath, opts) };
275
+ }
276
+
277
+ /**
278
+ * Run every filter, and report the ones that would not run.
279
+ *
280
+ * The I/O half, kept apart from the planner for the reason the whole engine
281
+ * keeps them apart: the plan is then assertable without a database. The build
282
+ * owns the statement — `SELECT * FROM notes WHERE <clause>` — which is what
283
+ * makes a filter unable to reach another package's schema, unable to project
284
+ * something that is not a note, and unable to pick up the `doc<type>`
285
+ * documentation rows that ride the same index as the notes they document.
286
+ *
287
+ * **A filter that selects nothing is an error**, and the distinction that makes
288
+ * that consistent is worth stating. A *note* no clause selects is expected: the
289
+ * book is a selection and a project decides what its own volume carries. A
290
+ * *clause* that selects no note is not the same thing — a filter is a deliberate
291
+ * act, so one matching nothing is either wrong or left over from a structure
292
+ * that has moved on, and in both cases the tree should not carry it. Reported
293
+ * with the section's name and the filter's position, so the choice between
294
+ * fixing it and deleting it is the author's.
295
+ *
296
+ * @param {object[]} nodes - From {@link parseDocumentTree}.
297
+ * @param {{query: (sql: string) => Promise<{rows: object[]}>}} db - An open
298
+ * database, from {@link module:engine/sql-tables.openNotesDatabase}.
299
+ * @param {object} [opts] - Options.
300
+ * @param {(record: object) => boolean} [opts.keep] - Which rows are notes.
301
+ * @param {string} [opts.text] - The document's source, for finding positions.
302
+ * @returns {Promise<{selections: Map<string, object[]>, findings: object[]}>}
303
+ */
304
+ export async function runTreeFilters(nodes, db, { keep = () => true, text } = {}) {
305
+ const selections = new Map();
306
+ const findings = [];
307
+ for (const node of nodes) {
308
+ for (const item of node.items) {
309
+ if (item.kind !== "filter") continue;
310
+ const where = `\`${node.trail.join(" › ")}\``;
311
+ try {
312
+ const { rows } = await db.query(`SELECT * FROM notes WHERE ${item.where}`);
313
+ const kept = rows.filter(keep);
314
+ selections.set(item.id, kept);
315
+ if (!kept.length) {
316
+ findings.push({
317
+ severity: "error",
318
+ message:
319
+ `${where}: the filter selected no notes — ` +
320
+ `\`${item.where}\` matches nothing in this project, so ` +
321
+ `either it is wrong or the section should not be here`,
322
+ ...position(text, [...item.keyPath, "filter"]),
323
+ });
324
+ }
325
+ } catch (err) {
326
+ findings.push({
327
+ severity: "error",
328
+ message:
329
+ `${where}: the filter did not run — ` + String(err.message).split("\n")[0],
330
+ ...position(text, [...item.keyPath, "filter"]),
331
+ });
332
+ selections.set(item.id, []);
333
+ }
334
+ }
335
+ }
336
+ return { selections, findings };
337
+ }
338
+
339
+ /**
340
+ * Resolve the flattened tree into the document plan.
341
+ *
342
+ * The plan is an ordered list of entries — `section`, `prose`, `note` — and it
343
+ * is the artifact worth having. Order, depth, the outline, the table of
344
+ * contents, anchor uniqueness and every link destination are all readable from
345
+ * it, so nearly the whole of what #316 asks for can be asserted here, on data,
346
+ * without a renderer or a PDF. Only how the result *looks* needs eyes.
347
+ *
348
+ * **Notes are sorted, prose is not.** A section's entries come out in
349
+ * `nameAscii` order — the ASCII fold `buildIndexRecord` already derives, so a
350
+ * circumflex sorts with its letter instead of after `Z` as a raw codepoint
351
+ * comparison would put it. Prose keeps the position it was written in, because
352
+ * its place in the sequence is the author's statement.
353
+ *
354
+ * **A section with nothing in it does not print**, and emptiness is judged after
355
+ * its descendants are: a section holding only sections that all resolved to
356
+ * nothing is itself empty. In a correct tree this never fires — a filter that
357
+ * selects nothing is reported by {@link runTreeFilters} as the error it is — so
358
+ * this is the graceful half of that failure rather than a feature: a build whose
359
+ * filters are broken still produces a readable document to look at while they
360
+ * are fixed. A section holding only prose is not empty; it is a preface.
361
+ *
362
+ * @param {object[]} nodes - From {@link parseDocumentTree}.
363
+ * @param {object} [opts] - Options.
364
+ * @param {Map<string, object[]>} [opts.selections] - Records each filter
365
+ * selected, keyed by item id. Run by the caller: DuckDB is async and this is
366
+ * not.
367
+ * @returns {{entries: object[], links: Map<string, string>, stats: object}}
368
+ * The plan, the address→anchor map inbound wikilinks resolve through, and
369
+ * what the selection came to.
370
+ */
371
+ export function planDocument(nodes, { selections = new Map() } = {}) {
372
+ const entries = [];
373
+ const links = new Map();
374
+ const seen = new Map();
375
+ let selected = 0;
376
+
377
+ // Depth-first in document order. `nodes` is already in that order, so a
378
+ // section's children are every later node whose trail begins with its own.
379
+ const roots = nodes.filter((n) => n.depth === 1);
380
+ for (const root of roots) emitSection(root, nodes, { entries, links, seen, selections });
381
+
382
+ for (const node of nodes) {
383
+ for (const item of node.items) {
384
+ if (item.kind === "filter") selected += (selections.get(item.id) ?? []).length;
385
+ }
386
+ }
387
+
388
+ return {
389
+ entries,
390
+ links,
391
+ stats: {
392
+ sections: entries.filter((e) => e.kind === "section").length,
393
+ prose: entries.filter((e) => e.kind === "prose").length,
394
+ notes: entries.filter((e) => e.kind === "note").length,
395
+ distinct: links.size,
396
+ repeated: selected - links.size,
397
+ skipped: nodes.length - entries.filter((e) => e.kind === "section").length,
398
+ },
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Emit one section and everything beneath it, or nothing if it is empty.
404
+ *
405
+ * Builds the section's own entries into a scratch list first, so the decision to
406
+ * drop it can be made after its children have answered — which is the only way
407
+ * a section of empty sections is itself recognised as empty.
408
+ *
409
+ * @param {object} node - The section.
410
+ * @param {object[]} nodes - Every node, in document order.
411
+ * @param {object} ctx - `{ entries, links, seen, selections }`.
412
+ * @returns {number} How many entries it contributed.
413
+ */
414
+ function emitSection(node, nodes, ctx) {
415
+ const start = ctx.entries.length;
416
+ ctx.entries.push({
417
+ kind: "section",
418
+ title: node.title,
419
+ trail: node.trail,
420
+ depth: node.depth,
421
+ presentation: node.presentation,
422
+ anchor: anchorFor(slugify(node.trail.join(" ")) || "section", ctx.seen),
423
+ });
424
+
425
+ const children = nodes.filter(
426
+ (n) =>
427
+ n.depth === node.depth + 1 &&
428
+ n.trail.length === node.trail.length + 1 &&
429
+ node.trail.every((t, i) => n.trail[i] === t),
430
+ );
431
+ let contributed = 0;
432
+
433
+ for (const item of node.items) {
434
+ if (item.kind === "prose") {
435
+ ctx.entries.push({
436
+ kind: "prose",
437
+ file: item.file,
438
+ trail: node.trail,
439
+ depth: node.depth,
440
+ presentation: node.presentation,
441
+ anchor: anchorFor(slugify(item.file.replace(/\.md$/i, "")) || "prose", ctx.seen),
442
+ });
443
+ contributed++;
444
+ continue;
445
+ }
446
+ for (const record of sortRecords(ctx.selections.get(item.id) ?? [])) {
447
+ const slug = record?.address?.slug ?? record?.shortcode ?? String(ctx.entries.length);
448
+ const anchor = anchorFor(slug, ctx.seen);
449
+ // First occurrence wins the address: however many times the book
450
+ // prints an entry, `[[weapongear-dagger]]` reaches one page.
451
+ if (!ctx.links.has(slug)) ctx.links.set(slug, anchor);
452
+ ctx.entries.push({
453
+ kind: "note",
454
+ record,
455
+ trail: node.trail,
456
+ depth: node.depth,
457
+ presentation: node.presentation,
458
+ anchor,
459
+ });
460
+ contributed++;
461
+ }
462
+ }
463
+
464
+ for (const child of children) contributed += emitSection(child, nodes, ctx);
465
+
466
+ if (!contributed) {
467
+ ctx.entries.length = start;
468
+ return 0;
469
+ }
470
+ return contributed + 1;
471
+ }
472
+
473
+ /**
474
+ * A section's notes, in the order the book prints them.
475
+ *
476
+ * Ordering is deliberately not in the file format: a filter says *which* notes,
477
+ * never in what sequence. The tiebreak on address keeps the result stable when
478
+ * two entries share a name, which a corpus of 2,500 does eventually.
479
+ *
480
+ * @param {object[]} records - Selected records.
481
+ * @returns {object[]} A sorted copy.
482
+ */
483
+ function sortRecords(records) {
484
+ return [...records].sort((a, b) => {
485
+ const an = a?.nameAscii ?? a?.name?.full ?? "";
486
+ const bn = b?.nameAscii ?? b?.name?.full ?? "";
487
+ if (an !== bn) return an < bn ? -1 : 1;
488
+ const as = a?.address?.slug ?? "";
489
+ const bs = b?.address?.slug ?? "";
490
+ return (
491
+ as < bs ? -1
492
+ : as > bs ? 1
493
+ : 0
494
+ );
495
+ });
496
+ }
497
+
498
+ /**
499
+ * A unique anchor, suffixed when the base is already taken.
500
+ *
501
+ * A note selected by two sections is two pages, and two pages cannot share a
502
+ * destination: the outline, the table of contents and any link would all reach
503
+ * whichever the renderer happened to emit last.
504
+ *
505
+ * @param {string} base - The preferred anchor.
506
+ * @param {Map<string, number>} seen - How many times each base has been used.
507
+ * @returns {string} The anchor.
508
+ */
509
+ function anchorFor(base, seen) {
510
+ const n = (seen.get(base) ?? 0) + 1;
511
+ seen.set(base, n);
512
+ return n === 1 ? base : `${base}-${n}`;
513
+ }
514
+
515
+ /**
516
+ * Where a node sits in the source, when the source was supplied.
517
+ *
518
+ * @param {string|undefined} text - The document source.
519
+ * @param {Array<string|number>} keyPath - Path to the node.
520
+ * @param {object} [opts] - Options.
521
+ * @returns {object} Spreadable position fields.
522
+ */
523
+ function position(text, keyPath, opts) {
524
+ return text ? positionOfYamlPath(text, keyPath, opts) : {};
525
+ }
package/engine/scenes.mjs CHANGED
@@ -115,6 +115,15 @@ export function collectKnownActionNames(repoRoot) {
115
115
  return names;
116
116
  }
117
117
 
118
+ /**
119
+ * Scenes pack compiler.
120
+ *
121
+ * Walks the content tree and compiles every map note into one Scene, resolving
122
+ * what one note says about another through an index built before any scene is
123
+ * written. It also writes one Adventure per place, bundling those scenes with
124
+ * the JournalEntries their prose compiled into, which is what makes a pinned
125
+ * scene's id-based references resolve on import.
126
+ */
118
127
  export class Scenes extends BasePackCompiler {
119
128
  static id = "scenes";
120
129
  static label = "map";
@@ -170,11 +179,6 @@ export class Scenes extends BasePackCompiler {
170
179
  return isMapType(fm.type);
171
180
  }
172
181
 
173
- /**
174
- * Collect every map note in the tree, and every item note's Active Effects.
175
- *
176
- * @returns {{maps: Array<object>, effectsByAddress: Map<string, object>}}
177
- */
178
182
  /**
179
183
  * The router this pass resolves pack names through.
180
184
  *
@@ -191,6 +195,11 @@ export class Scenes extends BasePackCompiler {
191
195
  return this.router ?? packRouter();
192
196
  }
193
197
 
198
+ /**
199
+ * Collect every map note in the tree, and every item note's Active Effects.
200
+ *
201
+ * @returns {{maps: Array<object>, effectsByAddress: Map<string, object>}}
202
+ */
194
203
  #collect() {
195
204
  const maps = [];
196
205
  const effectsByAddress = new Map();
@@ -692,7 +692,7 @@ export function checkAuthoredSystemData(
692
692
  * existed, or a subtype the artifact does not name, produces no findings.
693
693
  * `content-build lint` is where a missing artifact is said out loud, once.
694
694
  *
695
- * @param {object} system - The `system` block the compiler produced.
695
+ * @param {object} emitted - The `system` block the compiler produced.
696
696
  * @param {object} opts
697
697
  * @param {object} opts.fm - The note's frontmatter, for the authored paths this
698
698
  * check leaves to {@link checkAuthoredSystemData}.
@@ -425,7 +425,8 @@ export function writeHomepages(outRoot, pages, config) {
425
425
  * @param {object[]} pages - Every page, from both walks.
426
426
  * @param {object} findings - `{ addressFindings, fmLinkFindings }` from
427
427
  * collection.
428
- * @param {object} options - `{ config }`.
428
+ * @param {object} options
429
+ * @param {object} options.config - The resolved build configuration.
429
430
  * @returns {object} The gate results and, when they pass, the built index.
430
431
  */
431
432
  export function siteGates(pages, findings, { config }) {
@@ -608,7 +609,12 @@ export function sectionFrontmatter(meta) {
608
609
  * section's landing and takes the title and hero the section declares.
609
610
  *
610
611
  * @param {object} page - The page.
611
- * @param {object} options - `{ readmeSections, decorate }`.
612
+ * @param {object} options
613
+ * @param {Record<string, object>} [options.readmeSections] - The sections a
614
+ * published tree declares, which a tree page's own `README` is the landing
615
+ * for.
616
+ * @param {(data: object, page: object) => void} [options.decorate] - Called
617
+ * with each page's frontmatter, for whatever a consumer's own pass adds.
612
618
  * @returns {object} The frontmatter to write.
613
619
  */
614
620
  export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
@@ -800,7 +806,14 @@ export function renderPages(pages, options) {
800
806
  * navigation of every page inside it.
801
807
  *
802
808
  * @param {string} outRoot - The mount directory.
803
- * @param {object} options - `{ sections, landing, sectionTitle }`.
809
+ * @param {object} options
810
+ * @param {Record<string, object>} [options.sections] - The declared sections,
811
+ * each written as a titled `_index.md` carrying its own frontmatter.
812
+ * @param {object} [options.landing] - The mount's own landing frontmatter.
813
+ * Omitted, the mount gets no `_index.md` of its own.
814
+ * @param {((name: string) => string)|null} [options.sectionTitle] - Titles a
815
+ * directory below the mount that declared no section. `null` leaves such a
816
+ * directory without an `_index.md`.
804
817
  * @returns {number} How many landings were written.
805
818
  */
806
819
  export function writeSectionLandings(outRoot, { sections = {}, landing, sectionTitle }) {
@@ -944,6 +957,11 @@ export function resolveOutputRoot(rootDir, out) {
944
957
  * @param {object} [options.config] - A resolved configuration; loaded when
945
958
  * omitted.
946
959
  * @param {string} [options.outRoot] - Override the configured output mount.
960
+ * @param {Map<string, object[]>} [options.sqlTables] - Prepared `sql` results,
961
+ * keyed by the note's absolute file, from
962
+ * {@link module:engine/sql-tables.prepareSqlTables}. A page authoring an
963
+ * `sql` directive with none prepared is a table error: nothing here runs a
964
+ * query.
947
965
  * @returns {{gates: object, stats: object|null, tableErrors: object[],
948
966
  * wikiErrors: object[], manifests: object|null}}
949
967
  */
@@ -58,11 +58,10 @@ import { slugify } from "./content-slug.mjs";
58
58
 
59
59
  // Re-exported so a site build keeps one import path for the whole of link
60
60
  // resolution: the same rule that names a page also names an anchor within it.
61
+ /** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
61
62
  export { slugify };
62
63
  import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
63
64
 
64
- /** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
65
-
66
65
  /**
67
66
  * The index key a **piped** target resolves to, or `null` when it does not
68
67
  * parse as an address at all.
@@ -83,8 +82,12 @@ import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-s
83
82
  * The build indexes an item note under both `skill/climb` and `docskill/climb`,
84
83
  * and `contentTypes` carries both qualifiers, so either form finds the page.
85
84
  *
85
+ * @param {Map<string, object>} index - The page index to look in.
86
86
  * @param {object|null} read - From {@link readQualifier}.
87
- * @returns {string | null} The index key, or `null` when not an address.
87
+ * @param {string} contentPackage - The citing package, which an omitted package
88
+ * segment defaults from.
89
+ * @returns {object|undefined} The indexed page, or `undefined` when the target
90
+ * is not an address or nothing publishes it.
88
91
  */
89
92
  function lookupRead(index, read, contentPackage) {
90
93
  if (!read || read.reason) return undefined;
@@ -176,8 +176,8 @@ export function resolveItemDocType(qualifier, types) {
176
176
  *
177
177
  * **Parsing is plain positional counting**, the same rule
178
178
  * {@link readCanonicalKey} follows, and it is sound for the same reason: every
179
- * segment is `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN`, enforced on
180
- * shortcodes by `content-lint.mjs`), so the hyphen is purely a
179
+ * segment matches `ADDRESS_SEGMENT_PATTERN` (enforced on shortcodes by
180
+ * `content-lint.mjs`), so the hyphen is purely a
181
181
  * separator and the count alone determines every field. Verified across the
182
182
  * four content trees: 138,204 authored shortcodes, none carrying a separator.
183
183
  *
@@ -563,8 +563,6 @@ function draftLink(inner) {
563
563
  return `<span class="sohl-draft-link" title="Draft — not yet written">${inner}</span>`;
564
564
  }
565
565
 
566
- /** Matches a whole wikilink, capturing its inner text. */
567
-
568
566
  /**
569
567
  * Rewrites every wikilink in a markdown body as a Foundry UUID enricher.
570
568
  *