@heroiclands/package-build 0.6.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +68 -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 -35
  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
@@ -0,0 +1,374 @@
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
+ * Journals pack compiler — produces JSON pack files for the "journals"
16
+ * Foundry compendium from markdown notes in the `assets/content/` tree.
17
+ *
18
+ * The content root (`contentBase`) is walked recursively; any `.md` file
19
+ * whose frontmatter declares `package: sohl` and either `type: doc` or a
20
+ * **doc-carrying type** ({@link sohl.utils.packs.docEntryTypes} — every item
21
+ * type, plus `macro`) is compiled into one JournalEntry document. Each note's
22
+ * body is split on top-level H1 headings; the optional content before the
23
+ * first H1 becomes a lead page, and each subsequent H1 starts a new page named
24
+ * after its heading text. All page bodies are rendered to HTML.
25
+ *
26
+ * A doc-carrying note compiles into that document's **documentation** — the
27
+ * same prose and pages, filed in the same folder as the document itself, which
28
+ * keeps only a pointer to it. See `item-docs.mjs` for why, and for the ids the
29
+ * two passes agree on. A macro note's `{#script}` page is compiled here like
30
+ * any other: the macro pass reads the same page independently, and withholds
31
+ * nothing from the journal (#1514).
32
+ *
33
+ * Folder placement is identical to the items pack: `sohl.folder` in
34
+ * frontmatter is the target folder's id (from folders.yaml), resolved
35
+ * against a folders.yaml list via the constructor's `folderResolver`. A
36
+ * documentation entry reuses its document's folder id verbatim.
37
+ *
38
+ * Not a standalone script — exports the `Journals` compiler class, imported
39
+ * and driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
40
+ *
41
+ * The walk itself — filtering by package and type, skipping drafts,
42
+ * expanding tables, converting wikilinks, writing the JSON and counting
43
+ * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
44
+ * states only what makes this pass its own (#1509).
45
+ */
46
+
47
+ import log from "loglevel";
48
+
49
+ import {
50
+ sohlField,
51
+ makeId,
52
+ resolveName,
53
+ defaultStats,
54
+ md,
55
+ } from "./helpers.mjs";
56
+ import { BasePackCompiler } from "./base-compiler.mjs";
57
+ import { contentPackage } from "./content-package.mjs";
58
+ import { anchorPageId } from "./wikilinks.mjs";
59
+ import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
60
+
61
+ /**
62
+ * Splits a markdown body into pages by top-level H1 headings. Fenced
63
+ * code blocks are respected so `# foo` inside ``` blocks doesn't trigger
64
+ * a split. Content before the first H1 (if non-empty) becomes a leading
65
+ * page. Each H1 yields a page whose name is the heading
66
+ * text (with any `{#anchor-id}` suffix stripped out and surfaced as
67
+ * `anchorId`).
68
+ *
69
+ * `leadName` names that leading page. A journal note's is "Introduction",
70
+ * because it introduces the pages that follow. An item doc's is the item — a
71
+ * note with no headings at all is one page holding the whole description, and
72
+ * calling that page "Introduction" would label the description as a preamble to
73
+ * nothing.
74
+ *
75
+ * Returns an array of `{ name, anchorId, markdown }` in document order.
76
+ */
77
+ export function splitPages(body, leadName = "Introduction") {
78
+ const lines = body.split("\n");
79
+ const pages = [];
80
+ const beforeFirstH1 = [];
81
+ let current = null;
82
+ let inCodeBlock = false;
83
+
84
+ const closeCurrent = () => {
85
+ if (!current) return;
86
+ pages.push({
87
+ name: current.name,
88
+ anchorSlug: current.anchorSlug,
89
+ level: current.level,
90
+ markdown: current.lines.join("\n").trim(),
91
+ });
92
+ current = null;
93
+ };
94
+
95
+ for (const line of lines) {
96
+ if (line.trim().startsWith("```")) {
97
+ inCodeBlock = !inCodeBlock;
98
+ }
99
+
100
+ // An H1 starts a page, as does any heading carrying an `{#slug}`
101
+ // anchor: a Foundry UUID can only address a page, so a linkable
102
+ // section has to be one.
103
+ const headingMatch =
104
+ !inCodeBlock ? line.match(/^\s*(#{1,6})\s+(.+?)\s*#*\s*$/) : null;
105
+ const rawHeading = headingMatch?.[2]?.trim();
106
+ const anchorMatch = rawHeading?.match(/^(.*?)\s*\{#([^}]+)\}\s*$/);
107
+ const startsPage =
108
+ headingMatch && (headingMatch[1].length === 1 || anchorMatch);
109
+ if (startsPage) {
110
+ closeCurrent();
111
+ current = {
112
+ name: (anchorMatch ? anchorMatch[1] : rawHeading).trim(),
113
+ anchorSlug: anchorMatch?.[2]?.trim() || null,
114
+ level: headingMatch[1].length,
115
+ lines: [],
116
+ };
117
+ continue;
118
+ }
119
+
120
+ if (current) {
121
+ current.lines.push(line);
122
+ } else {
123
+ beforeFirstH1.push(line);
124
+ }
125
+ }
126
+ closeCurrent();
127
+
128
+ const intro = beforeFirstH1.join("\n").trim();
129
+ if (intro) {
130
+ pages.unshift({
131
+ name: leadName,
132
+ anchorSlug: null,
133
+ level: 1,
134
+ markdown: intro,
135
+ });
136
+ }
137
+
138
+ return pages;
139
+ }
140
+
141
+ /**
142
+ * Two headings in one note sharing an `{#anchor}` derive the same page id, which
143
+ * the LevelDB packer reports only as an opaque duplicate-key collision. Catch it
144
+ * here, where the note and the slug can be named.
145
+ *
146
+ * @param {Array<{anchorSlug: string|null}>} rawPages - From {@link splitPages}.
147
+ * @param {string} noteName - The note, for the error message.
148
+ * @throws {Error} When an anchor is declared twice in the same note.
149
+ */
150
+ export function assertUniqueAnchors(rawPages, noteName) {
151
+ const seen = new Set();
152
+ for (const page of rawPages) {
153
+ if (!page.anchorSlug) continue;
154
+ if (seen.has(page.anchorSlug)) {
155
+ throw new Error(
156
+ `note "${noteName}" declares the anchor {#${page.anchorSlug}} on more than one heading; an anchor must be unique within its note`,
157
+ );
158
+ }
159
+ seen.add(page.anchorSlug);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The id of one page within its entry.
165
+ *
166
+ * An anchored page takes the id its inbound links compute from the note id and
167
+ * the slug, so link and page agree without shared state. Every other page is
168
+ * keyed by its position and name, which is what lets the items pass address an
169
+ * item doc's first page without having compiled it (see
170
+ * {@link sohl.utils.packs.itemDocPointer}).
171
+ *
172
+ * @param {string} entryId - The owning JournalEntry's `_id`.
173
+ * @param {{anchorSlug: string|null, name: string}} page - From {@link splitPages}.
174
+ * @param {number} index - The page's position in the entry.
175
+ * @returns {string} A 16-character Foundry id.
176
+ */
177
+ export function journalPageId(entryId, page, index) {
178
+ return page.anchorSlug ?
179
+ anchorPageId(entryId, page.anchorSlug)
180
+ : makeId("journal-page", `${entryId}:${index}:${page.name}`);
181
+ }
182
+
183
+ /**
184
+ * Compile split pages into JournalEntryPage documents.
185
+ *
186
+ * @param {Array<object>} rawPages - From {@link splitPages}.
187
+ * @param {string} entryId - The owning JournalEntry's `_id`.
188
+ * @param {string} noteName - The note, for error messages.
189
+ * @returns {Array<{_id: string, name: string, type: string,
190
+ * title: {show: boolean, level: number},
191
+ * text: {format: number, content: string}, _key: string}>} The page
192
+ * documents, in order.
193
+ * @throws {Error} When the note has no content at all, or repeats an anchor.
194
+ */
195
+ export function buildPages(rawPages, entryId, noteName) {
196
+ if (rawPages.length === 0) {
197
+ throw new Error(
198
+ `note "${noteName}" has no Introduction content and no H1 headings — nothing to compile`,
199
+ );
200
+ }
201
+ assertUniqueAnchors(rawPages, noteName);
202
+ return rawPages.map((page, index) => {
203
+ const pageId = journalPageId(entryId, page, index);
204
+ return {
205
+ _id: pageId,
206
+ name: page.name,
207
+ type: "text",
208
+ title: { show: true, level: page.level ?? 1 },
209
+ text: {
210
+ format: 1,
211
+ content: page.markdown ? md.render(page.markdown) : "",
212
+ },
213
+ _key: `!journal.pages!${entryId}.${pageId}`,
214
+ };
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Assemble one JournalEntry document from a note's converted markdown.
220
+ *
221
+ * Shared with the scenes pass, which needs the *same* entry a map note's prose
222
+ * compiles into so it can bundle it into an Adventure alongside the Scene. Two
223
+ * passes deriving the same document from the same body is what keeps a map
224
+ * pin's `pageId` pointing at a page that actually exists.
225
+ *
226
+ * @param {object} params
227
+ * @param {string} params.id - The entry's `_id`.
228
+ * @param {string} params.name - The entry's name.
229
+ * @param {string} params.markdown - The body, tables expanded and wikilinks
230
+ * resolved.
231
+ * @param {string} [params.leadName] - Name for the page before the first
232
+ * heading; see {@link splitPages}.
233
+ * @param {string|null} [params.folder] - The folder id, or `null`.
234
+ * @param {object} [params.flags] - Document flags.
235
+ * @returns {object} The JournalEntry document, keyed for the pack.
236
+ */
237
+ export function buildJournalEntry({
238
+ id,
239
+ name,
240
+ markdown,
241
+ leadName,
242
+ folder = null,
243
+ flags,
244
+ }) {
245
+ const rawPages = splitPages(markdown, leadName);
246
+ const pages = buildPages(rawPages, id, name);
247
+ return {
248
+ name,
249
+ pages,
250
+ folder,
251
+ sort: 0,
252
+ ownership: { default: 0 },
253
+ flags: flags || {},
254
+ _id: id,
255
+ _stats: defaultStats(),
256
+ _key: `!journal!${id}`,
257
+ };
258
+ }
259
+
260
+ export class Journals extends BasePackCompiler {
261
+ static id = "journals";
262
+ static label = "journal";
263
+
264
+ /**
265
+ * A note with no id is skipped with a warning rather than failing the
266
+ * build: unlike an item or a macro, an unidentified journal note is prose
267
+ * that simply never became an entry.
268
+ */
269
+ static requiresId = false;
270
+
271
+ /**
272
+ * How many of the compiled entries were documentation for a document
273
+ * compiled elsewhere, for the summary.
274
+ *
275
+ * @type {number}
276
+ */
277
+ docEntries = 0;
278
+
279
+ /**
280
+ * Journal notes, plus every doc-carrying note — an item's prose is its
281
+ * documentation, so it compiles here and the item keeps a pointer to it
282
+ * (#1348); a macro's is the same arrangement (#1514), and so is a map's,
283
+ * whose prose is the place description its pins point at (#1525).
284
+ *
285
+ * The membership is {@link sohl.utils.packs.docEntryTypes}, read through
286
+ * {@link sohl.utils.packs.hasDocEntry} — the one set the link manifest also
287
+ * reads, so what compiles and what is published cannot drift apart.
288
+ *
289
+ * @param {object} fm - The note's frontmatter.
290
+ * @returns {boolean} True for a `doc` note or a doc-carrying note.
291
+ */
292
+ selects(fm) {
293
+ return fm.type === "doc" || hasDocEntry(fm.type);
294
+ }
295
+
296
+ /**
297
+ * An item with no prose gets no doc, and the items pass leaves its
298
+ * description empty rather than pointing at nothing; a map with no prose
299
+ * gets no entry and no pin target. The two passes apply the same rule to
300
+ * the same body, so they agree.
301
+ *
302
+ * @param {object} fm - The note's frontmatter.
303
+ * @param {string} body - The note body, frontmatter stripped.
304
+ * @returns {boolean} True to skip the note.
305
+ */
306
+ skipNote(fm, body) {
307
+ return hasDocEntry(fm.type) && !String(body).trim();
308
+ }
309
+
310
+ /**
311
+ * Compile one note into a JournalEntry.
312
+ *
313
+ * A `doc` note becomes the entry its frontmatter describes. A
314
+ * **doc-carrying note** — every item note, and every macro note — becomes
315
+ * that document's documentation instead: the same prose, the same pages,
316
+ * in the same folder, under an id derived from the note's, so the pointer
317
+ * the items pass wrote resolves to it (see
318
+ * {@link sohl.utils.packs.itemDocPointer}). A macro's `{#script}` page is
319
+ * compiled here like any other; nothing is withheld from the journal
320
+ * because the macro pass also reads it (#1514).
321
+ *
322
+ * @param {object} fm - The note's frontmatter.
323
+ * @param {string} markdown - The body, tables expanded and wikilinks
324
+ * resolved. The links are resolved from the note as authored — against
325
+ * the note's own id, not the entry's.
326
+ * @returns {object} The JournalEntry document.
327
+ */
328
+ buildEntry(fm, markdown) {
329
+ const name = resolveName(fm);
330
+ const ownsDoc = hasDocEntry(fm.type);
331
+ const id = ownsDoc ? itemDocEntryId(fm.id) : fm.id;
332
+
333
+ // A documentation entry is filed exactly where the document it
334
+ // describes is, so the journals pack mirrors the items pack and a doc
335
+ // sits under the same heading a reader found the item under. The id is
336
+ // taken verbatim rather than through `folderResolver`, which validates
337
+ // against this pack's own folders.yaml — an item folder is declared in
338
+ // the items one, a macro folder in the macros one, and a map's in the
339
+ // scenes one.
340
+ const folderId = sohlField(fm, "folder", null);
341
+ const folder = ownsDoc ? folderId : this.folderResolver(folderId);
342
+
343
+ return buildJournalEntry({
344
+ id,
345
+ name,
346
+ markdown,
347
+ // A doc-carrying note's lead page is the document itself, not an
348
+ // "Introduction" — see {@link splitPages}.
349
+ leadName: ownsDoc ? name : undefined,
350
+ folder,
351
+ flags: fm.flags,
352
+ });
353
+ }
354
+
355
+ /** @inheritdoc */
356
+ onCompiled(fm) {
357
+ if (hasDocEntry(fm.type)) this.docEntries++;
358
+ }
359
+
360
+ /** @inheritdoc */
361
+ reportCompiled(stats) {
362
+ log.info(
363
+ `Compiled ${stats.compiled} journal entr${stats.compiled === 1 ? "y" : "ies"} (${this.docEntries} documentation entr${this.docEntries === 1 ? "y" : "ies"})`,
364
+ );
365
+ }
366
+
367
+ /** @inheritdoc */
368
+ reportDetail(stats) {
369
+ log.debug(
370
+ `Skipped ${stats.skippedOther} non-doc file(s) ` +
371
+ `(not type:doc package:${contentPackage()})`,
372
+ );
373
+ }
374
+ }