@heroiclands/package-build 0.6.1 → 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 +58 -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,778 @@
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
+ * Publishing a content tree as a website (#63).
16
+ *
17
+ * Compiling a content tree into compendium packs is `content-build package
18
+ * compile`. Publishing the *same tree* as a website was a script each consumer
19
+ * wrote for itself — 473 code lines in `sohl` and 462 in `sohl-thalorna`, 87 of
20
+ * them identical — and the copies drifted in ways neither repository could see.
21
+ * `sohl-thalorna` reimplemented four things this package already exported, not
22
+ * because it needed different behaviour but because its script predates the
23
+ * extraction. That is the failure a command removes: a consumer cannot
24
+ * accidentally reimplement one.
25
+ *
26
+ * **What is here is the pass, not the framing.** The walk, the frontmatter read,
27
+ * the address derivation, the address index, table expansion, wikilink
28
+ * resolution, code-fence protection, the foreign-manifest merge, the page
29
+ * emission and the section-landing backfill are the same job everywhere. Where a
30
+ * page's address comes from (`publish.address`), what a section is called, and
31
+ * what a repository's own rewrites are, are not — they arrive as configuration
32
+ * and as a **named** pass bundle, since a configuration is data and cannot hold
33
+ * a function.
34
+ *
35
+ * **Every gate reports; none exits.** The integrity checks a site build needs —
36
+ * a wikilink authored in frontmatter, a name that yields no slug, two pages
37
+ * claiming one URL, an unusable or unaddressable foreign manifest, an address
38
+ * two packages both claim, a table directive that cannot be honoured, a dead
39
+ * wikilink — were inline `process.exit` calls in both scripts, with no test
40
+ * between them. Here each returns its findings and the command decides. That is
41
+ * the rule `engine/site-index.mjs` already follows, and it is the only reason
42
+ * these cases can be tested at all.
43
+ *
44
+ * @module
45
+ */
46
+
47
+ import fs from "node:fs";
48
+ import path from "node:path";
49
+ import { createRequire } from "node:module";
50
+ import matter from "gray-matter";
51
+
52
+ import { contentSlug, findSlugCollisions, slugify } from "./content-slug.mjs";
53
+ import { sectionOf } from "./content-address.mjs";
54
+ import { protectCode } from "./code-fences.mjs";
55
+ import { expandContentTables } from "./content-tables.mjs";
56
+ import { buildSiteIndex, wikiContext } from "./site-index.mjs";
57
+ import { frontmatterWikilinks, resolveWebWikilinks } from "./web-wikilinks.mjs";
58
+ import { loadForeignManifests, manifestsComplete } from "./kb-manifest.mjs";
59
+ import {
60
+ formatUnaddressableFinding,
61
+ unaddressableForeignPackages,
62
+ } from "./foreign-manifests.mjs";
63
+ import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
64
+ import { loadPackConfig } from "./pack-config.mjs";
65
+
66
+ const require = createRequire(import.meta.url);
67
+
68
+ /**
69
+ * Every `.md` file under `dir`, depth-first in directory order.
70
+ *
71
+ * Deliberately *not* {@link walkMarkdownTree}, whose stack-based walk yields a
72
+ * tree in reverse. Order is load-bearing here and nowhere else: the address
73
+ * index resolves a bare `[[Name]]` on a first-writer-wins basis, so reversing
74
+ * the walk silently changes which page an ambiguous name resolves to. A pack
75
+ * compile has no such dependency, which is why the two walks can differ.
76
+ *
77
+ * @param {string} dir - Directory to walk.
78
+ * @param {readonly string[]} skip - Directory names to ignore at any depth.
79
+ * @returns {string[]} Absolute paths.
80
+ */
81
+ export function walkSiteTree(dir, skip = []) {
82
+ const out = [];
83
+ if (!fs.existsSync(dir)) return out;
84
+ const skipped = new Set(skip);
85
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
86
+ const full = path.join(dir, e.name);
87
+ if (e.isDirectory()) {
88
+ if (skipped.has(e.name)) continue;
89
+ out.push(...walkSiteTree(full, skip));
90
+ } else if (e.isFile() && e.name.endsWith(".md")) {
91
+ out.push(full);
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * Reads a note, returning `null` for one that cannot be parsed.
99
+ *
100
+ * A file that is not front-mattered markdown is skipped rather than reported: a
101
+ * content tree is edited in Obsidian, which leaves files of its own about, and
102
+ * failing on one would make the build hostage to the editor.
103
+ *
104
+ * @param {string} file - Absolute path.
105
+ * @returns {{fm: object, body: string}|null}
106
+ */
107
+ function readNote(file) {
108
+ try {
109
+ const { data, content } = matter(fs.readFileSync(file, "utf8"));
110
+ return { fm: data, body: content };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The content tree's pages, and what could not be addressed.
118
+ *
119
+ * @param {string} contentBase - Absolute path to the content tree.
120
+ * @param {object} ctx - `{ packages, skipDirectories, mount, scheme }`.
121
+ * @returns {{pages: object[], slugFindings: object[], fmLinkFindings: object[]}}
122
+ */
123
+ export function collectContentPages(contentBase, ctx) {
124
+ const pages = [];
125
+ const slugFindings = [];
126
+ const fmLinkFindings = [];
127
+
128
+ for (const file of walkSiteTree(contentBase, ctx.skipDirectories)) {
129
+ const note = readNote(file);
130
+ if (!note) continue;
131
+ const { fm, body } = note;
132
+ if (!ctx.packages.has(fm.package) || !fm.type) continue;
133
+
134
+ for (const hit of frontmatterWikilinks(fm)) {
135
+ fmLinkFindings.push({ file, ...hit });
136
+ }
137
+
138
+ const name = fm.name?.full ?? path.basename(file, ".md");
139
+ // The URL segment derives from the name (#1278), never from the
140
+ // shortcode, which is identity referenced by saved world data rather
141
+ // than presentation.
142
+ let slug;
143
+ try {
144
+ slug = contentSlug(name);
145
+ } catch (err) {
146
+ slugFindings.push({ file, reason: err.message });
147
+ continue;
148
+ }
149
+
150
+ const base = path.basename(file);
151
+ const isReadme = base.toLowerCase() === "readme.md";
152
+ const sec = sectionOf(fm);
153
+ const rel = path.relative(contentBase, file);
154
+ pages.push({
155
+ kind: "content",
156
+ fm,
157
+ body,
158
+ name,
159
+ slug,
160
+ base,
161
+ // Top-level content directory ("Rules", "Skills", …). Wikilinks do
162
+ // not use it: a note is addressed as `type/shortcode` wherever it
163
+ // is filed.
164
+ tld: rel.split(path.sep)[0],
165
+ // Location below the content root, POSIX-separated — what a
166
+ // generated table reads as `file.path` and scopes on with `FROM`.
167
+ relPath: rel.split(path.sep).join("/"),
168
+ // The immediate source subfolder, the only surviving record of the
169
+ // authoring folder, for grouped landings.
170
+ folder: path.basename(path.dirname(file)),
171
+ sec,
172
+ url:
173
+ isReadme ?
174
+ `${ctx.mount}${sec}/`
175
+ : `${ctx.mount}${sec}/${slug}/`,
176
+ isReadme,
177
+ });
178
+ }
179
+ return { pages, slugFindings, fmLinkFindings };
180
+ }
181
+
182
+ /**
183
+ * An extra tree's pages — a documentation tree published alongside the content.
184
+ *
185
+ * These preserve their **source layout** below the section rather than being
186
+ * addressed by type and slug: they are a book with chapters, and a reader
187
+ * follows their paths. A `README` is its directory's landing.
188
+ *
189
+ * @param {object} tree - `{ from, rel, section, route }`.
190
+ * @param {object} ctx - `{ mount }`.
191
+ * @returns {{pages: object[], fmLinkFindings: object[]}}
192
+ */
193
+ export function collectTreePages(tree, ctx) {
194
+ const pages = [];
195
+ const fmLinkFindings = [];
196
+
197
+ for (const file of walkSiteTree(tree.from)) {
198
+ const note = readNote(file);
199
+ if (!note) continue;
200
+ const { fm, body } = note;
201
+
202
+ for (const hit of frontmatterWikilinks(fm)) {
203
+ fmLinkFindings.push({ file, ...hit });
204
+ }
205
+
206
+ const rel = path.relative(tree.from, file).replace(/\\/g, "/");
207
+ const base = path.basename(rel);
208
+ const isReadme = base.toLowerCase() === "readme.md";
209
+ const sec = fm.category ?? tree.section;
210
+ const h1 = /^#\s+(.+?)\s*$/m.exec(body);
211
+ const h1Title =
212
+ h1 ? h1[1].replace(/\{@link\s+[^}]*\}/g, "").trim() : null;
213
+ const name =
214
+ fm.name?.full ?? fm.title ?? h1Title ?? path.basename(base, ".md");
215
+ const slug = fm.slug ?? slugify(path.basename(base, ".md"));
216
+ const relNoExt = rel.slice(0, -3).toLowerCase();
217
+ const dir = path.posix.dirname(relNoExt);
218
+ pages.push({
219
+ kind: "tree",
220
+ tree,
221
+ fm,
222
+ // The H1 is stripped: the page title renders it.
223
+ body: body.replace(/^\s*#\s+.*$\r?\n?/m, ""),
224
+ name,
225
+ slug,
226
+ base,
227
+ rel,
228
+ sec,
229
+ url:
230
+ isReadme ?
231
+ dir === "." ?
232
+ `${ctx.mount}${sec}/`
233
+ : `${ctx.mount}${sec}/${dir}/`
234
+ : `${ctx.mount}${sec}/${relNoExt}/`,
235
+ isReadme,
236
+ });
237
+ }
238
+ return { pages, fmLinkFindings };
239
+ }
240
+
241
+ /**
242
+ * The integrity gates a site build runs before it writes anything.
243
+ *
244
+ * Every one of these was an inline `process.exit` in both consumer scripts, so
245
+ * none of them had a test. They are grouped here, reporting rather than exiting,
246
+ * because the order matters and the reasons are worth stating once:
247
+ *
248
+ * - **Frontmatter wikilinks** first, because frontmatter is copied to the page
249
+ * verbatim and a link written in one reaches the reader as literal `[[…]]`.
250
+ * - **Slugs and collisions** next: a note that derives no URL, or two that
251
+ * derive the same one, would silently drop or overwrite a page.
252
+ * - **Foreign manifests** last, in two steps. *Unusable* is a file this build
253
+ * cannot read; *unaddressable* is one it can read but cannot look anything up
254
+ * in — a distinction worth keeping, because the second surfaces as a pile of
255
+ * dead links blaming the notes that cite them rather than the file at fault.
256
+ *
257
+ * @param {object[]} pages - Every page, from both walks.
258
+ * @param {object} findings - `{ slugFindings, fmLinkFindings }` from collection.
259
+ * @param {object} options - `{ manifestDir }`.
260
+ * @returns {object} The gate results and, when they pass, the built index.
261
+ */
262
+ export function siteGates(pages, findings, { manifestDir }) {
263
+ const out = {
264
+ frontmatterLinks: findings.fmLinkFindings ?? [],
265
+ slugErrors: findings.slugFindings ?? [],
266
+ collisions: [],
267
+ staleManifests: [],
268
+ unaddressable: [],
269
+ conflicts: [],
270
+ index: null,
271
+ foreign: null,
272
+ manifests: null,
273
+ };
274
+ if (out.frontmatterLinks.length || out.slugErrors.length) return out;
275
+
276
+ const content = pages.filter((p) => p.kind === "content");
277
+ out.collisions = findSlugCollisions(
278
+ content.map((p) => ({
279
+ sec: p.sec,
280
+ slug: p.slug,
281
+ src: `${p.tld}/${p.base}`,
282
+ })),
283
+ );
284
+ if (out.collisions.length) return out;
285
+
286
+ // Which packages are *local* is what decides which manifests are foreign,
287
+ // and that is only known once the tree is walked — reading it from a
288
+ // configured list instead silently discarded the manifest of any package
289
+ // the list named but the tree did not contain.
290
+ const localPackages = new Set(content.map((p) => p.fm.package));
291
+ const foreign = loadForeignManifests(manifestDir, localPackages);
292
+ out.foreign = foreign;
293
+ if (foreign.stale.length) {
294
+ out.staleManifests = foreign.stale;
295
+ return out;
296
+ }
297
+
298
+ out.unaddressable = unaddressableForeignPackages(foreign.index);
299
+ if (out.unaddressable.length) return out;
300
+
301
+ out.manifests = manifestsComplete(localPackages, foreign.packages);
302
+ const index = buildSiteIndex(pages, { foreignIndex: foreign.index });
303
+ out.conflicts = index.conflicts;
304
+ if (out.conflicts.length) return out;
305
+
306
+ out.index = index;
307
+ return out;
308
+ }
309
+
310
+ /** Whether any gate produced a finding. */
311
+ export function gatesFailed(gates) {
312
+ return Boolean(
313
+ gates.frontmatterLinks.length ||
314
+ gates.slugErrors.length ||
315
+ gates.collisions.length ||
316
+ gates.staleManifests.length ||
317
+ gates.unaddressable.length ||
318
+ gates.conflicts.length,
319
+ );
320
+ }
321
+
322
+ /**
323
+ * The universe a generated table searches, grouped by package.
324
+ *
325
+ * Reference pages only, and grouped so a page never tabulates another package's
326
+ * content: a table is a claim about what this package ships.
327
+ *
328
+ * @param {object[]} pages - Every page.
329
+ * @returns {Map<string, object[]>} Package → the notes it may tabulate.
330
+ */
331
+ export function tableUniverse(pages) {
332
+ const byPackage = new Map();
333
+ for (const p of pages) {
334
+ if (p.kind !== "content") continue;
335
+ const pkg = p.fm.package;
336
+ if (!byPackage.has(pkg)) byPackage.set(pkg, []);
337
+ byPackage.get(pkg).push({
338
+ fm: p.fm,
339
+ path: p.relPath,
340
+ tld: p.tld,
341
+ folder: p.folder,
342
+ });
343
+ }
344
+ return byPackage;
345
+ }
346
+
347
+ /**
348
+ * The frontmatter a page publishes with.
349
+ *
350
+ * An authored `aliases` is Obsidian's — a list of *names* a reader might call
351
+ * the note, which is vault addressing and stays in the vault. Hugo reads
352
+ * `aliases` as **URL redirects**, so passing them through would publish a
353
+ * redirect stub at each name. They are dropped, and this build emits no
354
+ * redirects of its own.
355
+ *
356
+ * @param {object} page - The page.
357
+ * @param {object} options - `{ sections, readmeSections, decorate }`.
358
+ * @returns {object} The frontmatter to write.
359
+ */
360
+ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
361
+ const { fm, name, slug, sec, isReadme } = page;
362
+ let data;
363
+ if (page.kind === "content") {
364
+ data = { ...fm, slug, title: fm.title ?? name, kbfolder: page.folder };
365
+ if (decorate) decorate(data, page);
366
+ if (isReadme) {
367
+ const meta = readmeSections[sec];
368
+ if (meta) {
369
+ data.title = meta.title;
370
+ // Guarded: a title-only entry would otherwise emit
371
+ // `banner: undefined`, which the YAML serializer rejects.
372
+ if (meta.banner) data.banner = meta.banner;
373
+ }
374
+ }
375
+ } else {
376
+ // A tree's own landing describes the *mount*, and nothing beneath it. A
377
+ // nested README is a sub-section's landing, and reading the section's
378
+ // entry for it would title every one of them alike and hang the section
379
+ // hero on each. Its title comes from its H1, like any other page's.
380
+ const isSectionRoot = path.posix.dirname(page.rel) === ".";
381
+ const meta = isReadme && isSectionRoot ? readmeSections[sec] : null;
382
+ data = { ...fm, title: meta?.title ?? fm.title ?? name };
383
+ if (meta?.banner) data.banner = meta.banner;
384
+ }
385
+ delete data.aliases;
386
+ return data;
387
+ }
388
+
389
+ /** Where a page is written, relative to the output root. */
390
+ export function pageDestination(page) {
391
+ if (page.kind === "content") {
392
+ return page.isReadme ?
393
+ path.join(page.sec, "_index.md")
394
+ : path.join(page.sec, `${page.slug}.md`);
395
+ }
396
+ const rel =
397
+ page.isReadme ?
398
+ path.posix.join(path.posix.dirname(page.rel), "_index.md")
399
+ : page.rel;
400
+ return path.join(page.sec, rel);
401
+ }
402
+
403
+ /**
404
+ * Renders and writes every page.
405
+ *
406
+ * The order inside a page is load-bearing and is the same order the pack
407
+ * compilers use:
408
+ *
409
+ * 1. **Tables expand first**, and outside code-fence protection. A table is
410
+ * authored as a fenced `dataview` block, which `protectCode` would otherwise
411
+ * stash away before the expander saw it. Expanding first leaves an ordinary
412
+ * markdown table to walk, with every other fence still protected.
413
+ * 2. **Then, inside protection**: the consumer's `beforeLinks` pass, wikilink
414
+ * resolution, and the consumer's `afterLinks` pass. A `{@link}` tag may sit
415
+ * in prose a wikilink also touches, so the repository's own rewrites bracket
416
+ * the shared one rather than replacing it.
417
+ *
418
+ * @param {object[]} pages - Every page.
419
+ * @param {object} options - Everything the render needs.
420
+ * @returns {{written: number, byKind: Record<string, number>, tableErrors: object[], wikiErrors: object[]}}
421
+ */
422
+ export function renderPages(pages, options) {
423
+ const {
424
+ outRoot,
425
+ index,
426
+ foreign,
427
+ manifests,
428
+ universe,
429
+ pass = {},
430
+ readmeSections,
431
+ decorate,
432
+ linkable = (d) => Boolean(d.fm.shortcode),
433
+ } = options;
434
+
435
+ const tableErrors = [];
436
+ const wikiErrors = [];
437
+ const byKind = {};
438
+
439
+ for (const page of pages) {
440
+ const src = page.rel ?? `${page.sec}/${page.base}`;
441
+ const ctx = wikiContext(index, {
442
+ src,
443
+ type: page.fm.type ?? null,
444
+ errors: wikiErrors,
445
+ foreignIndex: foreign.index,
446
+ manifestsComplete: manifests.complete,
447
+ });
448
+
449
+ const resolve = (text) => {
450
+ let t = text;
451
+ if (pass.beforeLinks) t = pass.beforeLinks(t, page);
452
+ t = resolveWebWikilinks(t, ctx);
453
+ if (pass.afterLinks) t = pass.afterLinks(t, page);
454
+ return t;
455
+ };
456
+
457
+ let body = page.body;
458
+ if (page.kind === "content") {
459
+ const { markdown, errors } = expandContentTables(body, {
460
+ docs: universe.get(page.fm.package) ?? [],
461
+ linkable,
462
+ source: src,
463
+ self: { fm: page.fm, path: page.relPath },
464
+ });
465
+ tableErrors.push(...errors);
466
+ body = markdown;
467
+ }
468
+
469
+ const data = pageFrontmatter(page, { readmeSections, decorate });
470
+ const dest = path.join(outRoot, pageDestination(page));
471
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
472
+ fs.writeFileSync(
473
+ dest,
474
+ matter.stringify(protectCode(body, resolve), data),
475
+ );
476
+ byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
477
+ }
478
+
479
+ return { written: pages.length, byKind, tableErrors, wikiErrors };
480
+ }
481
+
482
+ /**
483
+ * Writes the section landings a published tree needs but no note supplies.
484
+ *
485
+ * Two separate jobs, and both exist because of how Hugo decides what a section
486
+ * is:
487
+ *
488
+ * - **Declared sections** get a titled `_index.md` with their hero, so a landing
489
+ * matches the card that links to it instead of showing Hugo's auto-humanised
490
+ * directory name. The body is empty, which lets the theme list the section's
491
+ * children — or say it is empty, for a section whose content has not shipped.
492
+ * - **Every other section directly under the mount** gets a bare `_index.md`,
493
+ * or its own address publishes nothing. Hugo generates a section page
494
+ * automatically only for a *top-level* content directory; below that, a
495
+ * directory without an `_index.md` is not a section, so its URL 404s while
496
+ * its children publish normally. Mounting a tree one level down demotes every
497
+ * section it holds, and the ones with no landing of their own quietly stop
498
+ * existing while every page inside them keeps working.
499
+ *
500
+ * Scoped to one level on purpose. A directory further down was not a section
501
+ * before the move either, and giving it one here would silently re-scope the
502
+ * prev/next navigation of every page inside it.
503
+ *
504
+ * @param {string} outRoot - The mount directory.
505
+ * @param {object} options - `{ sections, landing, sectionTitle }`.
506
+ * @returns {number} How many landings were written.
507
+ */
508
+ export function writeSectionLandings(
509
+ outRoot,
510
+ { sections = {}, landing, sectionTitle },
511
+ ) {
512
+ let written = 0;
513
+
514
+ // The mount's own landing carries a `type` of its own. Hugo's template
515
+ // lookup walks up a page's path, so a landing template at the mount would
516
+ // also serve every section below it that has no template of its own —
517
+ // each would render the mount's front page. Typing the landing moves its
518
+ // template out of the path where it could be inherited.
519
+ if (landing) {
520
+ fs.mkdirSync(outRoot, { recursive: true });
521
+ fs.writeFileSync(
522
+ path.join(outRoot, "_index.md"),
523
+ matter.stringify("", landing),
524
+ );
525
+ written += 1;
526
+ }
527
+
528
+ for (const [sec, meta] of Object.entries(sections)) {
529
+ const dir = path.join(outRoot, sec);
530
+ fs.mkdirSync(dir, { recursive: true });
531
+ // A section may have no hero: the images are CDN assets and not every
532
+ // section has one. An explicit `banner: undefined` is not a value YAML
533
+ // can carry, so the key is left off entirely.
534
+ fs.writeFileSync(
535
+ path.join(dir, "_index.md"),
536
+ matter.stringify("", {
537
+ title: meta.title,
538
+ ...(meta.banner ? { banner: meta.banner } : {}),
539
+ }),
540
+ );
541
+ written += 1;
542
+ }
543
+
544
+ if (!sectionTitle) return written;
545
+ for (const entry of fs.readdirSync(outRoot, { withFileTypes: true })) {
546
+ if (!entry.isDirectory()) continue;
547
+ const index = path.join(outRoot, entry.name, "_index.md");
548
+ if (fs.existsSync(index)) continue;
549
+ fs.writeFileSync(
550
+ index,
551
+ matter.stringify("", { title: sectionTitle(entry.name) }),
552
+ );
553
+ written += 1;
554
+ }
555
+ return written;
556
+ }
557
+
558
+ /**
559
+ * A section landing's title, from its directory name — `macro` → `Macros`.
560
+ *
561
+ * Hugo derives exactly this for a section page it generates itself, but not for
562
+ * one backed by an `_index.md`: an explicit file with no `title` renders a blank
563
+ * heading. So a backfilled landing states its own, in plain English
564
+ * pluralisation rather than Hugo's inflector, which spells that section
565
+ * "Macroes".
566
+ *
567
+ * @param {string} name - The directory name.
568
+ * @returns {string} The display title.
569
+ */
570
+ export function pluralTitle(name) {
571
+ const plural =
572
+ /(?:s|x|z|ch|sh)$/.test(name) ? `${name}es`
573
+ : /[^aeiou]y$/.test(name) ? `${name.slice(0, -1)}ies`
574
+ : `${name}s`;
575
+ return plural
576
+ .split("-")
577
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
578
+ .join(" ");
579
+ }
580
+
581
+ /**
582
+ * The named pass bundles a configuration may select.
583
+ *
584
+ * `site.pass` is the one part of the site contract that is *code* — a
585
+ * repository's own body rewrites — so a configuration names a bundle instead of
586
+ * supplying one, exactly as `itemBuilders` names an item registry. Required
587
+ * lazily, so importing this module does not drag a package-specific half of the
588
+ * toolchain in behind it.
589
+ *
590
+ * @type {Readonly<Record<string, () => Function>>}
591
+ */
592
+ const SITE_PASSES = Object.freeze({
593
+ sohlKb: () => require("../sohl/kb-passes.mjs").sohlKbPass,
594
+ });
595
+
596
+ /**
597
+ * Resolves `site.pass` to its bundle.
598
+ *
599
+ * @param {string|undefined} name - The configured name.
600
+ * @param {object} options - The configured options, plus `repoRoot`.
601
+ * @returns {{beforeLinks?: Function, afterLinks?: Function}} The bundle.
602
+ */
603
+ export function resolveSitePass(name, options) {
604
+ if (!name) return {};
605
+ const factory = SITE_PASSES[name];
606
+ if (!factory) {
607
+ throw new Error(
608
+ `unknown site pass ${JSON.stringify(name)} — expected one of ` +
609
+ `${Object.keys(SITE_PASSES).join(", ")}`,
610
+ );
611
+ }
612
+ return factory()(options);
613
+ }
614
+
615
+ /**
616
+ * The output root, having established that it is safe to delete.
617
+ *
618
+ * The whole tree is a build artifact and is wiped on every run, so this
619
+ * resolution is the difference between clearing a build directory and clearing
620
+ * the repository. An unset `site.out` resolves to `rootDir` itself, and the
621
+ * wipe then deletes the working tree — which is not a hypothetical: it happened
622
+ * while this module was being written, on a configuration that simply had no
623
+ * `site` section yet.
624
+ *
625
+ * So the path is refused unless it is **strictly inside** the repository root.
626
+ * Both failing shapes are ordinary rather than exotic — an absent setting, and a
627
+ * `..` that climbs out — and neither should be recoverable by being careful.
628
+ *
629
+ * @param {string} rootDir - The repository root.
630
+ * @param {string} out - The configured `site.out`.
631
+ * @returns {string} The absolute output root.
632
+ * @throws {Error} When it is unset, or is not below `rootDir`.
633
+ */
634
+ export function resolveOutputRoot(rootDir, out) {
635
+ if (!out) {
636
+ throw new Error(
637
+ "site.out is not set, so there is nowhere to write the site. " +
638
+ "Refusing to continue: the output directory is wiped on every " +
639
+ "run, and an unset one resolves to the repository root.",
640
+ );
641
+ }
642
+ const root = path.resolve(rootDir);
643
+ const resolved = path.resolve(root, out);
644
+ const inside = resolved !== root && resolved.startsWith(root + path.sep);
645
+ if (!inside) {
646
+ throw new Error(
647
+ `site.out (${JSON.stringify(out)}) resolves to ${resolved}, which ` +
648
+ `is not inside ${root}. Refusing to continue: that directory ` +
649
+ `is wiped on every run.`,
650
+ );
651
+ }
652
+ return resolved;
653
+ }
654
+
655
+ /**
656
+ * Builds a Hugo content tree from a content tree, and reports what it found.
657
+ *
658
+ * Returns rather than exits, in every case. A caller — the command, or a test —
659
+ * decides what a finding means; the gates below are grouped so it can report
660
+ * the first that fired and stop, which is what keeps a wall of dead links from
661
+ * burying the one manifest that caused them.
662
+ *
663
+ * @param {object} [options] - Options.
664
+ * @param {object} [options.config] - A resolved configuration; loaded when
665
+ * omitted.
666
+ * @param {string} [options.outRoot] - Override the configured output mount.
667
+ * @returns {{gates: object, stats: object|null, tableErrors: object[],
668
+ * wikiErrors: object[], manifests: object|null}}
669
+ */
670
+ export function buildSite({ config, outRoot } = {}) {
671
+ const resolved = config ?? loadPackConfig();
672
+ const site = resolved.site;
673
+ const scheme = resolved.publish.address;
674
+
675
+ // Where the package is served, and where its content mounts inside it. The
676
+ // two are separate facts: `base` is the package's own address on the site
677
+ // that publishes it, and `prefix` is the content tree's mount within the
678
+ // package — the same `prefix` the link manifest records against, so a page
679
+ // and its manifest entry cannot disagree.
680
+ const base = site.base || `/${resolved.contentPackage}/`;
681
+ const mount = `${base}${scheme.prefix}`;
682
+
683
+ // The Hugo content tree mirrors that mount: a page written to
684
+ // `<out>/<prefix>/<section>/` publishes at `<base><prefix><section>/`.
685
+ // Resolved against the repository root for the same reason every configured
686
+ // path is — so the build reads and writes the same places whatever
687
+ // directory it was launched from (#1508).
688
+ const outBase = resolveOutputRoot(resolved.rootDir, site.out);
689
+ const out =
690
+ outRoot ?
691
+ path.resolve(outRoot)
692
+ : path.join(outBase, scheme.prefix.replace(/\/$/, ""));
693
+
694
+ const packages = new Set(
695
+ site.packages.length ? site.packages : [resolved.contentPackage],
696
+ );
697
+
698
+ const ctx = {
699
+ packages,
700
+ skipDirectories: resolved.skipDirectories,
701
+ mount,
702
+ scheme,
703
+ };
704
+
705
+ // The whole tree is a build artifact, regenerated every run: a page whose
706
+ // note was deleted or renamed would otherwise linger and keep publishing.
707
+ fs.rmSync(outBase, { recursive: true, force: true });
708
+
709
+ const content = collectContentPages(resolved.paths.content, ctx);
710
+ const pages = [...content.pages];
711
+ const fmLinkFindings = [...content.fmLinkFindings];
712
+
713
+ const trees = site.trees.map((t) => ({
714
+ ...t,
715
+ from: path.resolve(resolved.rootDir, t.from),
716
+ route: `${mount}${t.section}/`,
717
+ }));
718
+ for (const tree of trees) {
719
+ const got = collectTreePages(tree, ctx);
720
+ pages.push(...got.pages);
721
+ fmLinkFindings.push(...got.fmLinkFindings);
722
+ }
723
+
724
+ const gates = siteGates(
725
+ pages,
726
+ { ...content, fmLinkFindings },
727
+ { manifestDir: resolved.paths.manifests },
728
+ );
729
+ if (gatesFailed(gates)) {
730
+ return {
731
+ gates,
732
+ stats: null,
733
+ tableErrors: [],
734
+ wikiErrors: [],
735
+ manifests: gates.manifests,
736
+ };
737
+ }
738
+
739
+ const pass = resolveSitePass(site.pass, {
740
+ ...site.passOptions,
741
+ repoRoot: resolved.rootDir,
742
+ });
743
+
744
+ const rendered = renderPages(pages, {
745
+ outRoot: out,
746
+ index: gates.index,
747
+ foreign: gates.foreign,
748
+ manifests: gates.manifests,
749
+ universe: tableUniverse(pages),
750
+ pass,
751
+ readmeSections: site.readmeSections,
752
+ // What counts as a being is the toolchain's to say, not a consumer's.
753
+ // Asking in a consumer's script is how one came to still be checking
754
+ // `character` and `creature` months after they were retired, and to
755
+ // publish 95 pages with empty sidebars for months without noticing.
756
+ decorate: (data, page) => {
757
+ if (isBeing(page.fm)) {
758
+ data.sohl = deriveBeingInfo(page.fm.sohl, gates.index.refIndex);
759
+ }
760
+ },
761
+ });
762
+
763
+ const landings = writeSectionLandings(out, {
764
+ sections: site.sections,
765
+ landing: site.landing,
766
+ sectionTitle: site.backfillSections ? pluralTitle : null,
767
+ });
768
+
769
+ return {
770
+ gates,
771
+ manifests: gates.manifests,
772
+ tableErrors: rendered.tableErrors,
773
+ wikiErrors: rendered.wikiErrors,
774
+ stats: { ...rendered.byKind, landings, out },
775
+ };
776
+ }
777
+
778
+ export { formatUnaddressableFinding };