@heroiclands/package-build 0.6.0 → 3.0.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 (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +95 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -34
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
package/coverage.mjs CHANGED
@@ -44,7 +44,7 @@
44
44
  */
45
45
 
46
46
  import ts from "typescript";
47
- import { positionOfLiteral } from "@heroiclands/content-build/engine/diagnostics";
47
+ import { positionOfLiteral } from "./engine/diagnostics.mjs";
48
48
 
49
49
  /**
50
50
  * One place a key is referenced, and how firmly.
package/e2e.mjs CHANGED
@@ -406,7 +406,7 @@ export async function seedTestWorld({
406
406
  throw new Error(
407
407
  "Cannot seed a world without knowing which Foundry generation it " +
408
408
  "is for. Declare `compatibility.minimum` at the top level of " +
409
- "content-build.config.yaml.",
409
+ "package-build.config.yaml.",
410
410
  );
411
411
  }
412
412
 
Binary file
@@ -0,0 +1,608 @@
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
+ * `BasePackCompiler` — the one compile loop every pack pass runs.
16
+ *
17
+ * Walking the content tree, rejecting what this build does not own, skipping
18
+ * drafts, expanding generated tables, converting wikilinks, writing the JSON
19
+ * and counting what failed are the same in every pass. They were written out
20
+ * once per pass — three times when this was filed, five by the time it landed —
21
+ * so a fix to any of them had to be made everywhere, and the passes drifted
22
+ * apart in exactly the places nobody was comparing (#1509).
23
+ *
24
+ * A pass now states only what makes it that pass:
25
+ *
26
+ * | Hook | What it decides |
27
+ * | ---- | --------------- |
28
+ * | {@link BasePackCompiler#selects} | Which notes this pack claims. **Required.** |
29
+ * | {@link BasePackCompiler#buildEntry} | One note → one document. **Required.** |
30
+ * | {@link BasePackCompiler#prepare} | Anything the walk needs first (an index, a prior pack's output). |
31
+ * | {@link BasePackCompiler#skipNote} | A further rejection the type filter cannot express. |
32
+ * | {@link BasePackCompiler#compileNote} | A note that emits *more* than its own document. |
33
+ * | {@link BasePackCompiler#onCompiled} | Per-note tallies for the summary. |
34
+ * | {@link BasePackCompiler#finish} | Work that needs every note first. |
35
+ * | {@link BasePackCompiler#reportCompiled} / {@link BasePackCompiler#reportDetail} | The pass's own log lines. |
36
+ *
37
+ * plus two static switches — `requiresId` (a note with no id is fatal, or
38
+ * merely skipped) and `convertsWikilinks` (whether the body reaching
39
+ * `buildEntry` is converted or exactly as authored).
40
+ *
41
+ * `selects` answers *which document type* a pass claims, and it is the same
42
+ * answer for every pack of that type. Which **pack of that type** a claimed
43
+ * note lands in is a second question, answered by the pack router from the
44
+ * note's own `pack:` declaration (#1566) — so a subclass never has to know that
45
+ * its type ships in more than one pack.
46
+ *
47
+ * **This is the extension point.** The pack list is data
48
+ * (`package-build.config.yaml`) and each entry names a document type; a consumer
49
+ * adding a document type this toolchain does not ship writes a subclass of this
50
+ * and registers it, rather than copying a pass and editing it. The contract the
51
+ * generator relies on stays small: construct, `await compile()`, read
52
+ * `errorCount` and `compiledCount`.
53
+ *
54
+ * **This class knows nothing about any game system.** Which types a pack claims
55
+ * arrives through `selects`, so the type membership stays in the one place that
56
+ * owns it — for the doc-carrying types, the single `docEntryTypes` set in
57
+ * `item-docs.mjs` that the compilers and the link manifest both read.
58
+ *
59
+ * @module
60
+ */
61
+
62
+ import fs from "fs";
63
+ import path from "path";
64
+ import log from "loglevel";
65
+
66
+ import {
67
+ walkMarkdownTree,
68
+ makeFilename,
69
+ resolveName,
70
+ buildContentLinkIndex,
71
+ convertNoteWikilinks,
72
+ collectContentDocs,
73
+ expandNoteTables,
74
+ } from "./helpers.mjs";
75
+ import { emitDiagnostic } from "./diagnostics.mjs";
76
+ import { contentPackage } from "./content-package.mjs";
77
+ import { assertTypeNotRetired, packForType } from "./ids.mjs";
78
+
79
+ /**
80
+ * The tallies one pass accumulates while walking the tree.
81
+ *
82
+ * @typedef {object} PassStats
83
+ * @property {number} compiled - Notes that became a document.
84
+ * @property {number} skippedDraft - Notes marked `draft: true`.
85
+ * @property {number} skippedNoId - Notes with no `id`, where that is tolerated.
86
+ * @property {number} skippedOther - Notes this pass does not claim.
87
+ */
88
+
89
+ /**
90
+ * The shared walk → filter → expand → convert → build → write → count loop.
91
+ *
92
+ * Subclass it, implement {@link BasePackCompiler#selects} and
93
+ * {@link BasePackCompiler#buildEntry}, and override the hooks the pass needs.
94
+ */
95
+ export class BasePackCompiler {
96
+ /**
97
+ * The pack this pass writes. Subclasses state their own.
98
+ *
99
+ * @type {string}
100
+ */
101
+ static id = "";
102
+
103
+ /**
104
+ * The singular noun this pass calls one of its notes, in log messages —
105
+ * "item", "journal", "actor", "macro", "map". Capitalized for the
106
+ * missing-id error.
107
+ *
108
+ * @type {string}
109
+ */
110
+ static label = "entry";
111
+
112
+ /**
113
+ * Whether a claimed note with no `id` fails the build.
114
+ *
115
+ * True everywhere but the journals pass: a skipped document silently
116
+ * vanishes from the compendium while its knowledgebase page still builds,
117
+ * so the omission is invisible until someone looks for it.
118
+ *
119
+ * @type {boolean}
120
+ */
121
+ static requiresId = true;
122
+
123
+ /**
124
+ * Whether the body handed to {@link BasePackCompiler#buildEntry} has had
125
+ * its generated tables expanded and its wikilinks converted.
126
+ *
127
+ * False for a pass whose output must be exactly what the author typed —
128
+ * the macros pass, whose `command` is executable source (#1514). A pass
129
+ * that says so also skips building the content-wide link index it would
130
+ * never read.
131
+ *
132
+ * @type {boolean}
133
+ */
134
+ static convertsWikilinks = true;
135
+
136
+ /** @type {string} */
137
+ contentBase;
138
+ /** @type {string} */
139
+ outputDir;
140
+ /** @type {(path: string|null) => string|null} */
141
+ folderResolver;
142
+ /** @type {number} */
143
+ errorCount = 0;
144
+
145
+ /**
146
+ * The pack this pass writes, and the Foundry document type it holds.
147
+ *
148
+ * Supplied by the generator from the configured pack list. Left undefined
149
+ * by a caller constructing a compiler directly (the unit suite), which
150
+ * turns routing off: with one pack there is nothing to route between.
151
+ *
152
+ * @type {string|undefined}
153
+ */
154
+ packName;
155
+ /** @type {string|undefined} */
156
+ docType;
157
+ /** @type {{resolve: Function}|undefined} */
158
+ router;
159
+
160
+ /**
161
+ * Whether this pass reports a note of its document type that routes
162
+ * nowhere.
163
+ *
164
+ * Every pack of a type claims the same notes, so all of them would report
165
+ * the same unroutable note. The **first configured pack of the type** owns
166
+ * the message, and the rest stay quiet — one error, named once, and the
167
+ * build still fails.
168
+ *
169
+ * @type {boolean}
170
+ */
171
+ routingReporter = false;
172
+
173
+ /**
174
+ * Entries this pass wrote to its own pack. Zero from a non-empty content
175
+ * tree is a build failure, not a quiet no-op — see `generate.mjs`.
176
+ *
177
+ * @type {number}
178
+ */
179
+ compiledCount = 0;
180
+
181
+ /**
182
+ * Wikilinks left as literal text because nothing in the tree (or in a
183
+ * vendored manifest) publishes their target.
184
+ *
185
+ * @type {number}
186
+ */
187
+ unresolvedLinks = 0;
188
+
189
+ /**
190
+ * @param {object} options
191
+ * @param {string} options.contentBase - Root of the content tree.
192
+ * @param {string} options.dest - Where this pass writes its JSON.
193
+ * @param {(path: string|null) => string|null} [options.folderResolver] -
194
+ * Resolves a `sohl.folder` id against this pack's folder hierarchy.
195
+ * @param {string} [options.packName] - The pack this pass writes.
196
+ * @param {string} [options.docType] - The Foundry document type it holds.
197
+ * @param {{resolve: Function}} [options.router] - The pack router. Omit it
198
+ * — as the unit suite does — and every claimed note is compiled here.
199
+ * @param {boolean} [options.routingReporter] - Whether this pass reports a
200
+ * note of its type that routes nowhere.
201
+ */
202
+ constructor({
203
+ contentBase,
204
+ dest,
205
+ folderResolver = () => null,
206
+ packName,
207
+ docType,
208
+ router,
209
+ routingReporter = false,
210
+ } = {}) {
211
+ if (!contentBase) {
212
+ throw new Error(
213
+ `${this.constructor.name} compiler requires \`contentBase\``,
214
+ );
215
+ }
216
+ if (!fs.existsSync(contentBase)) {
217
+ throw new Error(`Content tree not found at ${contentBase}`);
218
+ }
219
+ Object.defineProperty(this, "contentBase", {
220
+ value: contentBase,
221
+ writable: false,
222
+ });
223
+ Object.defineProperty(this, "outputDir", {
224
+ value: dest,
225
+ writable: false,
226
+ });
227
+ Object.defineProperty(this, "folderResolver", {
228
+ value: folderResolver,
229
+ writable: false,
230
+ });
231
+ this.packName = packName;
232
+ this.docType = docType;
233
+ this.router = router;
234
+ this.routingReporter = routingReporter;
235
+ }
236
+
237
+ /**
238
+ * Whether this pass's pack is the one a claimed note belongs in.
239
+ *
240
+ * @param {object} fm - The note's frontmatter.
241
+ * @returns {boolean} True to compile it here.
242
+ * @throws {import("./pack-router.mjs").PackRoutingError} When the note
243
+ * routes to no pack at all — a build failure, never a silent drop.
244
+ */
245
+ routesHere(fm) {
246
+ if (!this.router || !this.packName || !this.docType) return true;
247
+ return this.router.resolve(fm, this.docType) === this.packName;
248
+ }
249
+
250
+ /**
251
+ * Whether this pass claims a note. **Required.**
252
+ *
253
+ * Called only for a note of the configured content package, so a subclass
254
+ * decides on `type` alone.
255
+ *
256
+ * @param {object} fm - The note's frontmatter.
257
+ * @returns {boolean} True to compile it.
258
+ */
259
+ // eslint-disable-next-line no-unused-vars
260
+ selects(fm) {
261
+ throw new Error(
262
+ `${this.constructor.name} must implement selects(fm) — which notes this pack claims`,
263
+ );
264
+ }
265
+
266
+ /**
267
+ * A further rejection the type filter cannot express, applied after the
268
+ * id check. The journals pass uses it to skip a doc-carrying note with no
269
+ * prose: there is no documentation to compile, and the document's own pass
270
+ * leaves its pointer empty to match.
271
+ *
272
+ * @param {object} fm - The note's frontmatter.
273
+ * @param {string} body - The note body, as authored.
274
+ * @returns {boolean} True to skip the note.
275
+ */
276
+ // eslint-disable-next-line no-unused-vars
277
+ skipNote(fm, body) {
278
+ return false;
279
+ }
280
+
281
+ /**
282
+ * What one note is called in this pass's log lines. The items pass names
283
+ * the item's type, which is more use than "item".
284
+ *
285
+ * @param {object} fm - The note's frontmatter.
286
+ * @returns {string} The label.
287
+ */
288
+ // eslint-disable-next-line no-unused-vars
289
+ noteLabel(fm) {
290
+ return this.constructor.label;
291
+ }
292
+
293
+ /**
294
+ * Everything the walk needs before it starts: the content-wide link index
295
+ * and table-search corpus here, plus whatever a subclass adds (a prior
296
+ * pack's output, an index of cross-references).
297
+ *
298
+ * @returns {Promise<void>}
299
+ */
300
+ async prepare() {
301
+ if (this.constructor.convertsWikilinks) {
302
+ this.linkIndex = buildContentLinkIndex(
303
+ this.contentBase,
304
+ this.router,
305
+ );
306
+ this.contentDocs = collectContentDocs(this.contentBase);
307
+ }
308
+ this.unresolvedLinks = 0;
309
+ }
310
+
311
+ /**
312
+ * The body {@link BasePackCompiler#buildEntry} receives.
313
+ *
314
+ * Generated tables expand before wikilinks are converted, so a cell a
315
+ * table emits is resolved along with the authored links.
316
+ *
317
+ * @param {object} fm - The note's frontmatter.
318
+ * @param {string} body - The note body, as authored.
319
+ * @returns {string} The converted markdown, or `body` itself for a pass
320
+ * that does not convert.
321
+ */
322
+ convertBody(fm, body) {
323
+ if (!this.constructor.convertsWikilinks) return body;
324
+ const name = resolveName(fm);
325
+ const { absPath, bodyLine, bodyColumn } = this.currentNote ?? {};
326
+ const { markdown: tabulated, lineMap } = expandNoteTables(body, {
327
+ docs: this.contentDocs,
328
+ name,
329
+ pkg: fm.package,
330
+ fm,
331
+ bodyLine,
332
+ });
333
+ const { markdown, unresolved } = convertNoteWikilinks(tabulated, {
334
+ type: fm.type,
335
+ id: fm.id,
336
+ // Where this note is, so a link that resolves nowhere is reported
337
+ // at a position an author can open rather than by note name (#17).
338
+ file: absPath,
339
+ bodyLine,
340
+ bodyColumn,
341
+ lineMap,
342
+ // A `[[#slug]]` self-link addresses the source note, which has no
343
+ // entry in the index — so where its own documents landed has to
344
+ // travel with it (#1566).
345
+ pack: this.router?.resolveOrNull(fm, packForType(fm.type).docType),
346
+ docPack: this.router?.resolveOrNull(fm, "JournalEntry"),
347
+ index: this.linkIndex,
348
+ name,
349
+ });
350
+ this.unresolvedLinks += unresolved.length;
351
+ return markdown;
352
+ }
353
+
354
+ /**
355
+ * Reports a warning about the note being compiled.
356
+ *
357
+ * The file comes from the walk, so no caller has to carry it; a `position`
358
+ * is used when the caller could establish one and omitted otherwise —
359
+ * naming the file alone beats naming a line that is not the problem.
360
+ *
361
+ * @param {string} message - What is wrong, in one sentence.
362
+ * @param {{line?: number, column?: number}} [position] - Where, if known.
363
+ * @returns {void}
364
+ */
365
+ noteWarn(message, position) {
366
+ emitDiagnostic({
367
+ file: this.currentNote?.absPath,
368
+ line: position?.line,
369
+ column: position?.column,
370
+ severity: "warning",
371
+ message,
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Reports an error about the note being compiled.
377
+ *
378
+ * @param {string} message - What is wrong, in one sentence.
379
+ * @param {{line?: number, column?: number}} [position] - Where, if known.
380
+ * @returns {void}
381
+ */
382
+ noteError(message, position) {
383
+ emitDiagnostic({
384
+ file: this.currentNote?.absPath,
385
+ line: position?.line,
386
+ column: position?.column,
387
+ severity: "error",
388
+ message,
389
+ });
390
+ }
391
+
392
+ /**
393
+ * One note → one document. **Required.**
394
+ *
395
+ * @param {object} fm - The note's frontmatter.
396
+ * @param {string} markdown - The body, from
397
+ * {@link BasePackCompiler#convertBody}.
398
+ * @returns {object} The document, keyed for the pack.
399
+ */
400
+ // eslint-disable-next-line no-unused-vars
401
+ buildEntry(fm, markdown) {
402
+ throw new Error(
403
+ `${this.constructor.name} must implement buildEntry(fm, markdown)`,
404
+ );
405
+ }
406
+
407
+ /**
408
+ * Write one document into a directory, named for its name and id.
409
+ *
410
+ * @param {string} dir - The destination directory.
411
+ * @param {object} doc - The document.
412
+ */
413
+ writeTo(dir, doc) {
414
+ fs.writeFileSync(
415
+ path.join(dir, makeFilename(doc.name, doc._id)),
416
+ JSON.stringify(doc, null, 2),
417
+ "utf8",
418
+ );
419
+ }
420
+
421
+ /**
422
+ * Write one document into this pass's own pack.
423
+ *
424
+ * @param {object} doc - The document.
425
+ */
426
+ writeEntry(doc) {
427
+ this.writeTo(this.outputDir, doc);
428
+ }
429
+
430
+ /**
431
+ * Compile one claimed note. The default builds its document and writes it;
432
+ * a pass whose note emits more than that (the scenes pass, which also
433
+ * bundles an Adventure) overrides this.
434
+ *
435
+ * @param {object} fm - The note's frontmatter.
436
+ * @param {string} markdown - The body, from
437
+ * {@link BasePackCompiler#convertBody}.
438
+ * @returns {object} The document written to this pass's own pack.
439
+ */
440
+ compileNote(fm, markdown) {
441
+ const doc = this.buildEntry(fm, markdown);
442
+ this.writeEntry(doc);
443
+ return doc;
444
+ }
445
+
446
+ /**
447
+ * A note compiled successfully — where a pass keeps its own tallies.
448
+ *
449
+ * @param {object} fm - The note's frontmatter.
450
+ * @param {object} doc - The document just written.
451
+ */
452
+ // eslint-disable-next-line no-unused-vars
453
+ onCompiled(fm, doc) {}
454
+
455
+ /**
456
+ * Work that needs every note compiled first, before the summary is logged.
457
+ *
458
+ * @param {PassStats} stats - The pass's tallies.
459
+ * @returns {Promise<void>}
460
+ */
461
+ // eslint-disable-next-line no-unused-vars
462
+ async finish(stats) {}
463
+
464
+ /**
465
+ * The pass's headline count.
466
+ *
467
+ * @param {PassStats} stats - The pass's tallies.
468
+ */
469
+ reportCompiled(stats) {
470
+ const label = this.constructor.label;
471
+ log.info(
472
+ `Compiled ${stats.compiled} ${label}${stats.compiled === 1 ? "" : "s"}`,
473
+ );
474
+ }
475
+
476
+ /**
477
+ * The pass's own trailing detail line, which names what it rejected in the
478
+ * terms that pass uses.
479
+ *
480
+ * @param {PassStats} stats - The pass's tallies.
481
+ */
482
+ reportDetail(stats) {
483
+ log.debug(
484
+ `Skipped ${stats.skippedOther} file(s) this pack does not claim`,
485
+ );
486
+ }
487
+
488
+ /**
489
+ * Log what the pass did.
490
+ *
491
+ * @param {PassStats} stats - The pass's tallies.
492
+ */
493
+ report(stats) {
494
+ this.reportCompiled(stats);
495
+ if (this.unresolvedLinks) {
496
+ log.info(
497
+ `${this.unresolvedLinks} wikilink(s) left as literal text (no target in the content tree)`,
498
+ );
499
+ }
500
+ if (stats.skippedNoId) {
501
+ log.info(`Skipped ${stats.skippedNoId} note(s) missing id`);
502
+ }
503
+ if (stats.skippedDraft) {
504
+ log.info(`Skipped ${stats.skippedDraft} draft(s)`);
505
+ }
506
+ this.reportDetail(stats);
507
+ }
508
+
509
+ /**
510
+ * Walk the content tree and compile every note this pass claims.
511
+ *
512
+ * @returns {Promise<void>}
513
+ */
514
+ async compile() {
515
+ /** @type {PassStats} */
516
+ const stats = {
517
+ compiled: 0,
518
+ skippedDraft: 0,
519
+ skippedNoId: 0,
520
+ skippedOther: 0,
521
+ };
522
+ await this.prepare();
523
+
524
+ const label = this.constructor.label;
525
+ const Label = label.charAt(0).toUpperCase() + label.slice(1);
526
+
527
+ for (const {
528
+ frontmatter: fm,
529
+ body,
530
+ absPath,
531
+ bodyLine,
532
+ bodyColumn,
533
+ } of walkMarkdownTree(this.contentBase)) {
534
+ // Which note this pass is on, so anything it calls can report a
535
+ // position without every method having to be handed one (#17).
536
+ this.currentNote = { absPath, bodyLine, bodyColumn };
537
+ if (!fm || fm.package !== contentPackage()) {
538
+ stats.skippedOther++;
539
+ continue;
540
+ }
541
+ // Checked before `selects`, and therefore for every note this
542
+ // package owns rather than only the ones some pass claims. A
543
+ // retired type is claimed by no pass, so the alternative is not a
544
+ // wrong document — it is no document, skipped as quietly as the
545
+ // thousands of notes that legitimately belong to another pass.
546
+ assertTypeNotRetired(fm.type, absPath);
547
+ if (!this.selects(fm)) {
548
+ stats.skippedOther++;
549
+ continue;
550
+ }
551
+ if (fm.draft === true) {
552
+ stats.skippedDraft++;
553
+ log.debug(`Skipping draft: ${absPath}`);
554
+ continue;
555
+ }
556
+ if (!fm.id) {
557
+ if (this.constructor.requiresId) {
558
+ throw new Error(`${Label} missing id: ${absPath}`);
559
+ }
560
+ stats.skippedNoId++;
561
+ this.noteWarn(`${label} note has no id, skipping`);
562
+ continue;
563
+ }
564
+ // Which pack of this type takes it. Applied after the draft and
565
+ // id checks — a draft is not compiled anywhere, so its declaration
566
+ // is nobody's business — and before `skipNote`, so a note this pack
567
+ // does not own never reaches this pass's own rejection rules.
568
+ try {
569
+ if (!this.routesHere(fm)) {
570
+ stats.skippedOther++;
571
+ continue;
572
+ }
573
+ } catch (err) {
574
+ if (this.routingReporter) {
575
+ this.errorCount++;
576
+ this.noteError(err.message, err.position);
577
+ }
578
+ continue;
579
+ }
580
+ if (this.skipNote(fm, body)) {
581
+ stats.skippedOther++;
582
+ continue;
583
+ }
584
+
585
+ log.debug(
586
+ `Processing ${this.noteLabel(fm)}: ${resolveName(fm)} (${absPath})`,
587
+ );
588
+ try {
589
+ const doc = this.compileNote(fm, this.convertBody(fm, body));
590
+ stats.compiled++;
591
+ this.onCompiled(fm, doc);
592
+ } catch (err) {
593
+ this.errorCount++;
594
+ // `position` is set by whatever failed if it knew where — an
595
+ // unresolved address, a bad table directive — so the report
596
+ // points at the line rather than at the note (#17).
597
+ this.noteError(
598
+ `${this.noteLabel(fm)} failed to compile: ${err.message}`,
599
+ err.position,
600
+ );
601
+ }
602
+ }
603
+
604
+ this.compiledCount = stats.compiled;
605
+ await this.finish(stats);
606
+ this.report(stats);
607
+ }
608
+ }