@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,809 @@
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
+ * Shared helpers for the pack compilers in `packages/content-build/`.
16
+ *
17
+ * The HeroicLands vault is authoritative for compendium item data. Pack
18
+ * compilers walk the vault, read markdown files with YAML frontmatter, and
19
+ * emit Foundry-compatible JSON. These helpers handle the common shape:
20
+ * markdown parsing, frontmatter access (including the nested `sohl:` block),
21
+ * filename generation, and slug normalization.
22
+ *
23
+ * Not a standalone script — a shared helper module imported by the pack
24
+ * generation orchestrator and compilers (generate.mjs, items.mjs,
25
+ * journals.mjs, actors.mjs).
26
+ */
27
+
28
+ import fs from "fs";
29
+ import crypto from "crypto";
30
+ import path from "path";
31
+ import yaml from "yaml";
32
+ import unidecode from "unidecode";
33
+ import markdownit from "markdown-it";
34
+ import log from "loglevel";
35
+
36
+ import { loadPackConfig } from "./pack-config.mjs";
37
+ import { packRouter } from "./pack-router.mjs";
38
+ import { contentPackage, foundryPackageId } from "./content-package.mjs";
39
+ import { loadForeignManifests, PACKAGE_BASE } from "./kb-manifest.mjs";
40
+ import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
41
+ import { expandContentTables } from "./content-tables.mjs";
42
+ import { emitDiagnostic, positionInBody } from "./diagnostics.mjs";
43
+ // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
44
+ // registry can import them without reaching back through this one (#1504).
45
+ // Re-exported here so every existing importer keeps its single import path.
46
+ import { getFrontmatter } from "./frontmatter.mjs";
47
+ export {
48
+ getFrontmatter,
49
+ sohlField,
50
+ resolveCharges,
51
+ resolveSkillAptitudes,
52
+ resolveRelation,
53
+ requireSubType,
54
+ parseValueDesc,
55
+ } from "./frontmatter.mjs";
56
+
57
+ export const md = markdownit({ html: true });
58
+
59
+ /**
60
+ * Parses a markdown file with YAML frontmatter.
61
+ *
62
+ * Returns `{ frontmatter, body, description, bodyLine, bodyColumn }` where
63
+ * `body` is the trimmed raw markdown after the frontmatter block, and
64
+ * `description` is `body` rendered to HTML. `bodyLine` / `bodyColumn` are the
65
+ * 1-based **file** position of the body's first character, which is what turns
66
+ * an offset within `body` into a position a diagnostic can name (#17) — see
67
+ * {@link positionInBody}. If the file has no frontmatter block, returns
68
+ * `{ frontmatter: null, body: "", description: "" }` with a warn log, and no
69
+ * position: there is no body to have one.
70
+ */
71
+ export function parseMarkdownFile(filePath) {
72
+ const content = fs.readFileSync(filePath, "utf8");
73
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
74
+ if (!fmMatch) {
75
+ return { frontmatter: null, body: "", description: "" };
76
+ }
77
+ let frontmatter;
78
+ try {
79
+ frontmatter = yaml.parse(fmMatch[1]) || {};
80
+ } catch (err) {
81
+ log.warn(`YAML parse error in ${filePath}: ${err.message}`);
82
+ return { frontmatter: null, body: "", description: "" };
83
+ }
84
+ const raw = fmMatch[2];
85
+ const body = raw.trim();
86
+ const description = body ? md.render(body) : "";
87
+ // Where the trimmed body starts in the *file*, so an offset within it can
88
+ // be reported as a file position (#17). The frontmatter's lines and the
89
+ // blank lines `trim()` removes both sit in between, and the trim can take
90
+ // indentation off the first line as well — hence a column, not just a line.
91
+ const bodyStart =
92
+ content.length - raw.length + (raw.length - raw.trimStart().length);
93
+ const before = content.slice(0, bodyStart);
94
+ const bodyLine = before.split("\n").length;
95
+ const bodyColumn = bodyStart - before.lastIndexOf("\n");
96
+ return { frontmatter, body, description, bodyLine, bodyColumn };
97
+ }
98
+
99
+ /**
100
+ * Recursively yields every `.md` file under `rootDir`, parsed.
101
+ * Yields `{ frontmatter, body, description, file, absPath, bodyLine,
102
+ * bodyColumn }` for each match — the last two from
103
+ * {@link parseMarkdownFile}, so a caller can report a position inside the
104
+ * body as a position in the file (#17).
105
+ * Silently skips directories that don't exist.
106
+ *
107
+ * Directory names in `skipDirectories` are ignored wherever they appear. The
108
+ * walk itself knows nothing about what they mean: `Templates/` is an Obsidian
109
+ * templater convention this repository's vault happens to use, not a property
110
+ * of a content tree, so it is configured rather than hard-coded (#1508).
111
+ *
112
+ * @param {string} rootDir - Root of the tree to walk.
113
+ * @param {object} [opts]
114
+ * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
115
+ * Defaults to the configured list.
116
+ */
117
+ export function* walkMarkdownTree(
118
+ rootDir,
119
+ { skipDirectories = loadPackConfig().skipDirectories } = {},
120
+ ) {
121
+ if (!fs.existsSync(rootDir)) return;
122
+ const stack = [rootDir];
123
+ while (stack.length > 0) {
124
+ const dir = stack.pop();
125
+ let entries;
126
+ try {
127
+ entries = fs.readdirSync(dir, { withFileTypes: true });
128
+ } catch (err) {
129
+ log.warn(`Cannot read directory ${dir}: ${err.message}`);
130
+ continue;
131
+ }
132
+ for (const entry of entries) {
133
+ const absPath = path.join(dir, entry.name);
134
+ if (entry.isDirectory()) {
135
+ if (skipDirectories.includes(entry.name)) continue;
136
+ stack.push(absPath);
137
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
138
+ yield {
139
+ ...parseMarkdownFile(absPath),
140
+ file: entry.name,
141
+ absPath,
142
+ };
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Resolve the required `sohl.archetype` frontmatter for an Item/Actor entry
150
+ * (see the archetype contract, #604 — `flags.sohl.docArchetype`). The property
151
+ * is a nullable number that authors must state explicitly:
152
+ * - a number → the document is an archetype of that priority.
153
+ * - `null` → the document is not an archetype.
154
+ * - absent → an authoring error (throws), so "not an archetype" is never
155
+ * silently assumed.
156
+ *
157
+ * Reads `sohl.archetype`, falling back to a top-level `archetype` key to match
158
+ * {@link sohlField}'s nested-then-top-level resolution.
159
+ *
160
+ * @param {object} fm Parsed frontmatter.
161
+ * @param {string} label Human-readable context for error messages.
162
+ * @returns {number|undefined} The archetype priority, or `undefined` when null.
163
+ * @throws {Error} When `sohl.archetype` is absent or is not a number/null.
164
+ */
165
+ export function resolveArchetype(fm, label) {
166
+ const sohl = fm != null && typeof fm.sohl === "object" ? fm.sohl : null;
167
+ const inSohl = sohl != null && "archetype" in sohl;
168
+ const inTop = fm != null && typeof fm === "object" && "archetype" in fm;
169
+ if (!inSohl && !inTop) {
170
+ throw new Error(
171
+ `Missing required sohl.archetype for ${label} — set a number (this is an archetype) or null (it is not)`,
172
+ );
173
+ }
174
+ const raw = inSohl ? sohl.archetype : fm.archetype;
175
+ if (raw === null) return undefined;
176
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
177
+ throw new Error(
178
+ `Invalid sohl.archetype for ${label}: expected a number or null, got ${JSON.stringify(raw)}`,
179
+ );
180
+ }
181
+ return raw;
182
+ }
183
+
184
+ /**
185
+ * Merge the required `sohl.archetype` frontmatter into a document's `flags`,
186
+ * returning a new object (the input is never mutated). A numeric archetype
187
+ * seeds `flags.sohl.docArchetype`; `null` omits the flag (and clears any stale
188
+ * `docArchetype` while preserving sibling `sohl` flags); an absent value
189
+ * throws. See {@link resolveArchetype}.
190
+ *
191
+ * @param {object} fm Parsed frontmatter.
192
+ * @param {object} [flags] The entry's existing flags (e.g. `fm.flags`).
193
+ * @param {string} label Human-readable context for error messages.
194
+ * @returns {object} The flags object with the archetype applied.
195
+ * @throws {Error} When `sohl.archetype` is absent or invalid.
196
+ */
197
+ export function withArchetypeFlag(fm, flags, label) {
198
+ const archetype = resolveArchetype(fm, label);
199
+ const out = { ...(flags || {}) };
200
+ const sohl = { ...(out.sohl || {}) };
201
+ if (archetype === undefined) {
202
+ delete sohl.docArchetype;
203
+ } else {
204
+ sohl.docArchetype = archetype;
205
+ }
206
+ if (Object.keys(sohl).length > 0) out.sohl = sohl;
207
+ else delete out.sohl;
208
+ return out;
209
+ }
210
+
211
+ /**
212
+ * Generates a compendium-source filename: `Name_id.json` with non-
213
+ * alphanumeric runs replaced by underscores.
214
+ */
215
+ export function makeFilename(name, id) {
216
+ return `${unidecode(name)}_${id}`.replace(/[^0-9a-zA-Z]+/g, "_") + ".json";
217
+ }
218
+
219
+ /**
220
+ * Standardize a name into a slug: lowercase, apostrophes removed,
221
+ * non-alphanumerics collapsed to single hyphens.
222
+ */
223
+
224
+ /**
225
+ * Translate a content-relative image path into its Foundry-relative form.
226
+ *
227
+ * Content frontmatter (`img` / `portrait`) authors a single path that has to
228
+ * work for Foundry, the knowledgebase, and the website. For Foundry the bundled
229
+ * asset roots — `icons/...` and `images/...` — are served from the package
230
+ * directory, so they are rewritten to `<assetRoot>/<path>` — `systems/sohl/assets`
231
+ * for this repository, `modules/<id>/assets` for a module (#1508). Any other
232
+ * path (already package-rooted, an absolute URL) is returned unchanged, and an
233
+ * empty path yields `""`.
234
+ *
235
+ * This is translation only: the per-type default for an empty result is
236
+ * domain-specific (actors default differently from items, and gear differently
237
+ * again), so each compiler owns its own default and applies it to the result —
238
+ * `resolveImg(fm.img) || <default>`. For items that default is the art paired
239
+ * with the type's builder, reached through `itemArt()`, which runs the path
240
+ * back through this function so a registry entry and a note's `img:` are
241
+ * spelled the same way (#7).
242
+ *
243
+ * @param {string | null | undefined} raw - content-relative path from frontmatter.
244
+ * @param {{assetRoot: string}} [config] - The resolved build configuration.
245
+ * Defaults to this repository's.
246
+ * @returns {string} the Foundry-relative path, or `""` when `raw` is empty.
247
+ */
248
+ export function resolveImg(raw, config = loadPackConfig()) {
249
+ if (!raw) return "";
250
+ const s = String(raw);
251
+ if (s.startsWith("icons/") || s.startsWith("images/")) {
252
+ return `${config.assetRoot}/${s}`;
253
+ }
254
+ return s;
255
+ }
256
+
257
+ /**
258
+ * Resolves the display name from frontmatter, preferring `name.full`,
259
+ * falling back to `name` (if string), then `defaultValue`.
260
+ */
261
+ export function resolveName(fm, defaultValue = "Unnamed") {
262
+ const fullName = getFrontmatter(fm, "name.full", null);
263
+ if (fullName) return String(fullName);
264
+ if (typeof fm?.name === "string") return fm.name;
265
+ return defaultValue;
266
+ }
267
+
268
+ /**
269
+ * The oldest Foundry core this package supports, stamped into every compiled
270
+ * document as `_stats.coreVersion`.
271
+ *
272
+ * **Derived, never written twice.** `_stats.coreVersion` is what Foundry gates
273
+ * its migration shims on: a record stamped older than a shim is rewritten by it
274
+ * on load. Every pack once shipped `coreVersion: "14"`, which sorts *below*
275
+ * every v14 build and so left all shipped content permanently eligible for
276
+ * every v14 migration (#1533).
277
+ *
278
+ * **Read from the configuration, not from the shipped manifest.** It used to
279
+ * open `paths.packageManifest` and take `compatibility.minimum` out of it,
280
+ * because the configuration had no way to state the fact. Now that it does, the
281
+ * manifest is generated *from* the configuration — so reading it back would be
282
+ * a round trip through an artifact that need not exist yet: `build:db` can run
283
+ * before the manifest is written.
284
+ *
285
+ * Absent is a hard failure, not a default. A guessed floor is stamped into
286
+ * every document in the pack and stays invisible until something migrates on
287
+ * it, which is exactly why the manifest read threw rather than falling back.
288
+ *
289
+ * @param {{compatibility: {minimum: string}|null}} [config] - The resolved
290
+ * configuration. Defaults to this repository's.
291
+ * @returns {string} The declared `compatibility.minimum`.
292
+ * @throws {Error} When the configuration declares no `compatibility.minimum`.
293
+ */
294
+ export function supportedCoreVersion(config = loadPackConfig()) {
295
+ const minimum = config.compatibility?.minimum;
296
+ if (!minimum) {
297
+ throw new Error(
298
+ "package-build: the configuration declares no " +
299
+ "`compatibility.minimum`, so compiled documents have no " +
300
+ "honest core version to stamp. Declare it at the top level of " +
301
+ "package-build.config.yaml.",
302
+ );
303
+ }
304
+ return String(minimum);
305
+ }
306
+
307
+ /**
308
+ * Default `_stats` block for compiled compendium entries.
309
+ *
310
+ * Every stamped identity is configuration (#1508): four compilers used to pass
311
+ * the same frozen `"0.6.0"` literal, and `systemId` / `lastModifiedBy` were
312
+ * written into this function. `coreVersion` alone is *not* configuration — it
313
+ * comes from {@link supportedCoreVersion}, the configured Foundry floor,
314
+ * so a document never claims to predate the migrations that would rewrite it.
315
+ *
316
+ * @param {string} [systemVersion] - The system version to stamp. Defaults to the
317
+ * configured one.
318
+ * @param {{stats: {systemId: string, systemVersion: string,
319
+ * lastModifiedBy: string}, paths: {packageManifest: string}}} [config] -
320
+ * The resolved build configuration. Defaults to this repository's.
321
+ * @returns {object} The `_stats` block.
322
+ */
323
+ export function buildStats(
324
+ systemVersion = undefined,
325
+ config = loadPackConfig(),
326
+ ) {
327
+ return {
328
+ systemId: config.stats.systemId,
329
+ systemVersion: systemVersion ?? config.stats.systemVersion,
330
+ coreVersion: supportedCoreVersion(config),
331
+ createdTime: 0,
332
+ modifiedTime: 0,
333
+ lastModifiedBy: config.stats.lastModifiedBy,
334
+ };
335
+ }
336
+
337
+ /** Memoised {@link defaultStats}. */
338
+ let cachedDefaultStats;
339
+
340
+ /**
341
+ * The `_stats` block every compiler stamps on an entry it emits, built once.
342
+ *
343
+ * Each compiler used to hoist `const STATS = buildStats()` at module scope,
344
+ * which read the shipped package manifest the moment the module was imported —
345
+ * so importing a compiler required a manifest to exist even when nothing was
346
+ * going to be compiled (#2). Deferred to first use and memoised here, the
347
+ * cost and the identity are what they always were; only the moment moved.
348
+ *
349
+ * @returns {object} The default `_stats` block, shared by every compiler.
350
+ */
351
+ export function defaultStats() {
352
+ cachedDefaultStats ??= buildStats();
353
+ return cachedDefaultStats;
354
+ }
355
+
356
+ /**
357
+ * Stable 16-char hex id derived from `${namespace}:${value}`.
358
+ *
359
+ * Defined in {@link sohl.utils.packs.ids} — a leaf module, so that the link
360
+ * resolver this one imports can derive ids too — and re-exported here for the
361
+ * passes that have always reached it through `helpers`.
362
+ */
363
+ // The one slug rule, re-exported so callers keep a single import path.
364
+ export { slugify } from "./content-slug.mjs";
365
+
366
+ export { makeId } from "./ids.mjs";
367
+
368
+ // The content-type → document-type map, which decides *which* pack list a
369
+ // note's own document is routed against.
370
+ import { assertTypeNotRetired, packForType } from "./ids.mjs";
371
+
372
+ /* ------------------------------------------------------------------------ */
373
+ /* Wikilink resolution: the content-wide link index */
374
+ /* ------------------------------------------------------------------------ */
375
+
376
+ /**
377
+ * Indexes **every** note in the content tree so any pack compiler can resolve a
378
+ * wikilink to any other document. Shared by all three compilers: a skill links
379
+ * to another skill, a journal to a creature, a creature to a rules page, and
380
+ * each target's own **type** decides which pack the UUID points into.
381
+ *
382
+ * Each note's pack is resolved here, once, and stored on its index entry: a
383
+ * UUID carries a pack name, so a repository shipping several packs of one type
384
+ * (#1566) would otherwise address every one of them as the first. A note whose
385
+ * declaration is unroutable is indexed against the conventional name and left
386
+ * for the compile pass to report — the index has no business failing a build,
387
+ * and the pass fails it with a far better message. The one exception is a
388
+ * **retired** content type (SoHL#1580): this walk is the first to see every
389
+ * note together with its path, and unlike an unroutable declaration there is
390
+ * no pass that would ever claim such a note and report it.
391
+ *
392
+ * @param {string} contentBase - Root of the content tree.
393
+ * @param {object} [router] - The pack router. Supplied by the calling pass so
394
+ * the index and the compile agree about where each note landed; defaults to
395
+ * this repository's own.
396
+ * @returns {{byShortcode: Map, byAlias: Map}} From `buildWikilinkIndex`.
397
+ */
398
+ export function buildContentLinkIndex(contentBase, router = packRouter()) {
399
+ const docs = [];
400
+ for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase)) {
401
+ if (!fm?.id) continue;
402
+ // The first walk of every note in the tree, and the only one holding
403
+ // both the declared type and the file that declares it — so a note
404
+ // left on a retired type is reported here, by name, rather than
405
+ // several frames deeper with nothing to go on (SoHL#1580).
406
+ assertTypeNotRetired(fm.type, absPath);
407
+ const base = path.basename(absPath, ".md").replace(/_/g, " ");
408
+ docs.push({
409
+ type: fm.type,
410
+ id: fm.id,
411
+ // Where this note's own document lands, and where the JournalEntry
412
+ // its prose compiles into lands — two documents, two packs (#1362).
413
+ pack: router.resolveOrNull(fm, packForType(fm.type).docType),
414
+ docPack: router.resolveOrNull(fm, "JournalEntry"),
415
+ shortcode: fm.shortcode ?? null,
416
+ name: fm.name?.full ?? base,
417
+ aliases: [
418
+ ...(Array.isArray(fm.aliases) ? fm.aliases : []),
419
+ ...(fm.name?.full ? [fm.name.full] : []),
420
+ ...(Array.isArray(fm.name?.aliases) ? fm.name.aliases : []),
421
+ base,
422
+ ].filter(Boolean),
423
+ });
424
+ }
425
+ // Packages this build links *into* but does not publish. Their manifests
426
+ // are vendored and committed, so a contributor without every repository
427
+ // checked out resolves the same links CI does (#1446, #1499).
428
+ // Packages this repository links into but does not publish; their vendored
429
+ // manifests live at the configured location (#1446, #1499).
430
+ const { index: foreign, stale } = loadForeignManifests(
431
+ loadPackConfig().paths.manifests,
432
+ [contentPackage()],
433
+ PACKAGE_BASE,
434
+ );
435
+ if (stale.length) {
436
+ for (const st of stale) {
437
+ log.error(
438
+ `Unusable link manifest for "${st.package}": ${st.reason}`,
439
+ );
440
+ }
441
+ throw new Error(
442
+ "Cross-package links cannot be resolved from a stale manifest; " +
443
+ "re-vendor it from that package's build.",
444
+ );
445
+ }
446
+ log.debug(
447
+ `Wikilink index: ${docs.length} local document(s), ` +
448
+ `${foreign.size} foreign address(es)`,
449
+ );
450
+ return buildWikilinkIndex(
451
+ docs,
452
+ foundryPackageId(),
453
+ foreign,
454
+ contentPackage(),
455
+ );
456
+ }
457
+
458
+ /**
459
+ * Converts the wikilinks in one note's markdown, reporting any that have no
460
+ * target in the content tree. Every compiler funnels through this so the
461
+ * diagnostic text and the leave-it-alone fallback are identical everywhere.
462
+ *
463
+ * Each report names the **file, line and column** the link sits on (#17), so
464
+ * it can be opened and fixed — and so two identical links on one note are
465
+ * tellable apart. That needs `file` and the note's `bodyLine` / `bodyColumn`;
466
+ * without them the diagnostic still reports, one field shorter, rather than
467
+ * inventing a position.
468
+ *
469
+ * @param {string} body - The note's markdown body, tables already expanded.
470
+ * @param {object} ctx - `{ type, id, pack, docPack, index, name }` — `name` is
471
+ * used in the message, and the two pack names address a `[[#slug]]`
472
+ * self-link, whose target is the source note itself and so has no index
473
+ * entry. Position is carried by `{ file, bodyLine, bodyColumn, lineMap }`,
474
+ * the last from {@link expandNoteTables}.
475
+ * @returns {{markdown: string, unresolved: Array<object>}}
476
+ * @throws {Error} On an ambiguous alias or a dead qualified address. The error
477
+ * carries `file` and `position`, so a caller reports it in the same form
478
+ * rather than re-deriving one.
479
+ */
480
+ export function convertNoteWikilinks(
481
+ body,
482
+ {
483
+ type,
484
+ id,
485
+ pack,
486
+ docPack,
487
+ index,
488
+ name,
489
+ file,
490
+ bodyLine,
491
+ bodyColumn,
492
+ lineMap,
493
+ },
494
+ ) {
495
+ const result = convertWikilinks(body ?? "", {
496
+ type,
497
+ id,
498
+ pack,
499
+ docPack,
500
+ index,
501
+ });
502
+ /**
503
+ * Where one unresolved link sits, in file coordinates.
504
+ *
505
+ * @param {object} u - An entry of `result.unresolved`.
506
+ * @returns {{line?: number, column?: number, generated?: boolean}} Empty
507
+ * when the caller supplied no position to resolve against.
508
+ */
509
+ const locate = (u) =>
510
+ bodyLine === undefined || u.offset === undefined ?
511
+ {}
512
+ : positionInBody(body ?? "", u.offset, {
513
+ bodyLine,
514
+ bodyColumn,
515
+ lineMap,
516
+ });
517
+
518
+ /**
519
+ * Fails the note, carrying the position for the caller to report.
520
+ *
521
+ * @param {object} u - The offending link.
522
+ * @param {string} message - What is wrong.
523
+ * @returns {never}
524
+ */
525
+ const fail = (u, message) => {
526
+ const err = new Error(message);
527
+ err.file = file;
528
+ err.position = locate(u);
529
+ throw err;
530
+ };
531
+
532
+ for (const u of result.unresolved) {
533
+ // An ambiguous alias matched real content — twice. There is no
534
+ // defensible way to pick one, and the correction is mechanical: write
535
+ // the qualified form. So it fails rather than warning, which also puts
536
+ // the failure in front of whoever created the collision instead of
537
+ // leaving it in a log line attributed to an innocent citing note.
538
+ // The knowledgebase build has always treated this as fatal; agreeing
539
+ // means one authored note cannot get two verdicts (#13).
540
+ if (u.reason === "ambiguous") {
541
+ const claims = u.candidates ?? [];
542
+ const named =
543
+ claims.length ?
544
+ claims
545
+ .map((c) => `"${c.name}" (${c.type}-${c.shortcode})`)
546
+ .join(" and ")
547
+ : "two or more notes";
548
+ fail(
549
+ u,
550
+ `ambiguous wikilink ${u.link} in "${name}" — claimed by ` +
551
+ `${named}. Rename one alias, or address the intended one ` +
552
+ `as [[type-shortcode|Text]].`,
553
+ );
554
+ }
555
+ // A qualified address resolving nowhere is a typo, now that every
556
+ // linkable package is either built here or vendored (#1499) — so it
557
+ // fails the note rather than degrading to text. A bare alias stays a
558
+ // warning: it may be ordinary prose that merely looks like a link.
559
+ if (u.addressed) {
560
+ fail(
561
+ u,
562
+ `unresolved address ${u.link} in "${name}" — no package ` +
563
+ `publishes it. Fix the shortcode, or re-vendor that ` +
564
+ `package's manifest into assets/manifests/.`,
565
+ );
566
+ }
567
+ const at = locate(u);
568
+ emitDiagnostic({
569
+ file,
570
+ line: at.line,
571
+ column: at.column,
572
+ severity: "warning",
573
+ message:
574
+ `unresolved wikilink ${u.link} (${u.reason}) in "${name}"` +
575
+ // A link this build wrote is not at any authored position, so
576
+ // say where it came from instead of implying an edit site.
577
+ (at.generated ?
578
+ " — emitted by the content table on this line"
579
+ : ""),
580
+ });
581
+ }
582
+ return result;
583
+ }
584
+
585
+ /* ------------------------------------------------------------------------ */
586
+ /* Generated tables: the searchable content universe */
587
+ /* ------------------------------------------------------------------------ */
588
+
589
+ /**
590
+ * Every note in the content tree, in the shape the `dataview` table expander
591
+ * searches: its frontmatter plus where it sits in the tree. Ordered by path so
592
+ * a table that leaves rows tied still emits identically on every build.
593
+ *
594
+ * @param {string} contentBase - Root of the content tree.
595
+ * @returns {Array<{fm: object, path: string, tld: string, folder: string,
596
+ * absPath: string}>}
597
+ */
598
+ export function collectContentDocs(contentBase) {
599
+ const docs = [];
600
+ for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase)) {
601
+ if (!fm) continue;
602
+ const segments = path.relative(contentBase, absPath).split(path.sep);
603
+ docs.push({
604
+ fm,
605
+ // POSIX-separated and relative to the content root — what a
606
+ // `path:` search term globs, on every platform.
607
+ path: segments.join("/"),
608
+ tld: segments[0],
609
+ folder: segments[segments.length - 2] ?? segments[0],
610
+ absPath,
611
+ });
612
+ }
613
+ docs.sort((a, b) =>
614
+ a.absPath < b.absPath ? -1
615
+ : a.absPath > b.absPath ? 1
616
+ : 0,
617
+ );
618
+ log.debug(`Content table index: ${docs.length} searchable note(s)`);
619
+ return docs;
620
+ }
621
+
622
+ /**
623
+ * A note is linkable from a generated table cell when it carries the identity
624
+ * {@link convertWikilinks} addresses it by — a `type` and a `shortcode`. Every
625
+ * type routes to a pack ({@link packForType}), so nothing else can make a note
626
+ * unlinkable; a note missing either renders as plain text rather than shipping a
627
+ * literal wikilink into a journal.
628
+ */
629
+ const packLinkable = (doc) =>
630
+ Boolean(doc.fm?.shortcode) && Boolean(doc.fm?.type);
631
+
632
+ /**
633
+ * Expand the fenced `dataview` tables in one note's markdown, before wikilinks
634
+ * are resolved — so a generated cell may itself be a wikilink.
635
+ *
636
+ * A table searches only notes of the source note's own `package`, so a SoHL
637
+ * page never tabulates setting-package content (and vice versa).
638
+ *
639
+ * @param {string} body - The note's markdown body.
640
+ * @param {object} ctx
641
+ * @param {Array<object>} ctx.docs - From {@link collectContentDocs}.
642
+ * @param {string} ctx.name - The note, for the error message.
643
+ * @param {string} [ctx.pkg] - The source note's `package`.
644
+ * @param {object} [ctx.fm] - The source note's frontmatter, which is what a
645
+ * query's `this` reads. Its entry in `docs` supplies the path as well.
646
+ * @param {number} [ctx.bodyLine] - 1-based file line of the body's first line,
647
+ * so a failing directive can be reported at its position in the file.
648
+ * @returns {{markdown: string, lineMap: Array<{line: number,
649
+ * generated: boolean}>}} The body with every table expanded, and where each
650
+ * emitted line came from — which is what lets a diagnostic about the
651
+ * expanded body name an authored position (#17).
652
+ * @throws {Error} When a query is malformed or unsupported — the note fails to
653
+ * compile rather than shipping a table-shaped hole. The error carries
654
+ * `position`, the directive's own line.
655
+ */
656
+ export function expandNoteTables(body, { docs, name, pkg, fm, bodyLine }) {
657
+ const scoped = pkg ? docs.filter((d) => d.fm?.package === pkg) : docs;
658
+ const self =
659
+ fm ?
660
+ (docs.find((d) => d.fm?.id && d.fm.id === fm.id) ?? { fm })
661
+ : undefined;
662
+ const { markdown, errors, lineMap } = expandContentTables(body ?? "", {
663
+ docs: scoped,
664
+ linkable: packLinkable,
665
+ source: name,
666
+ self,
667
+ });
668
+ if (errors.length) {
669
+ const err = new Error(
670
+ errors.map((e) => `content table — ${e.reason}`).join("; "),
671
+ );
672
+ // The first failing directive's line. Reporting one position for a
673
+ // message that may name several is honest here: a caller opens the
674
+ // file at the first thing to fix, and the message lists the rest.
675
+ if (bodyLine !== undefined && errors[0].line !== undefined) {
676
+ err.position = { line: bodyLine + errors[0].line };
677
+ }
678
+ throw err;
679
+ }
680
+ return { markdown, lineMap };
681
+ }
682
+
683
+ /* ------------------------------------------------------------------------ */
684
+ /* Folder hierarchy: loading, resolution, emission */
685
+ /* ------------------------------------------------------------------------ */
686
+
687
+ /**
688
+ * Loads a folders.yaml file as an array of folder entries. Returns []
689
+ * when the file is missing (logging a warning) so packs without folders
690
+ * can opt out simply by not committing the file.
691
+ */
692
+ export function loadFolders(foldersFile) {
693
+ if (!fs.existsSync(foldersFile)) {
694
+ log.warn(
695
+ `No folders.yaml at ${foldersFile}; no folders will be emitted`,
696
+ );
697
+ return [];
698
+ }
699
+ const raw = fs.readFileSync(foldersFile, "utf8");
700
+ const parsed = yaml.parse(raw);
701
+ if (parsed == null) return [];
702
+ if (!Array.isArray(parsed)) {
703
+ throw new Error(
704
+ `folders.yaml must contain a YAML list; got ${typeof parsed}`,
705
+ );
706
+ }
707
+ return parsed;
708
+ }
709
+
710
+ /**
711
+ * Validates folder invariants and returns a resolver function that maps a
712
+ * folder id to the same id (after verifying it exists). Returns `null` for
713
+ * a null/empty input; throws for an unknown id.
714
+ *
715
+ * Invariants:
716
+ * - Every folder must have a non-empty id
717
+ * - Every folder must have a name
718
+ * - Sibling folders (same parentFolderId) must have unique names
719
+ * - Every parentFolderId must match an existing folder id (or be "")
720
+ *
721
+ * Returns { resolver, folders } where folders is the validated list.
722
+ */
723
+ export function buildFolderResolver(folders) {
724
+ const byId = new Map();
725
+ for (const f of folders) {
726
+ if (!f.id) {
727
+ throw new Error(`Folder missing id: ${JSON.stringify(f)}`);
728
+ }
729
+ if (!f.name) {
730
+ throw new Error(`Folder ${f.id} missing name`);
731
+ }
732
+ if (byId.has(f.id)) {
733
+ throw new Error(`Duplicate folder id ${f.id}`);
734
+ }
735
+ byId.set(f.id, f);
736
+ }
737
+
738
+ const siblingsByParent = new Map();
739
+ for (const f of folders) {
740
+ const parentId = f.parentFolderId || "";
741
+ if (parentId && !byId.has(parentId)) {
742
+ throw new Error(
743
+ `Folder ${f.id} (${f.name}) references unknown parentFolderId ${parentId}`,
744
+ );
745
+ }
746
+ if (!siblingsByParent.has(parentId)) {
747
+ siblingsByParent.set(parentId, new Set());
748
+ }
749
+ const siblings = siblingsByParent.get(parentId);
750
+ if (siblings.has(f.name)) {
751
+ throw new Error(
752
+ `Sibling folders share name "${f.name}" under parent ${parentId || "(root)"} — names must be unique among siblings`,
753
+ );
754
+ }
755
+ siblings.add(f.name);
756
+ }
757
+
758
+ function resolver(folderId) {
759
+ if (folderId == null || folderId === "") return null;
760
+ const id = String(folderId).trim();
761
+ if (!id) return null;
762
+ if (!byId.has(id)) {
763
+ throw new Error(`Unknown folder id "${id}"`);
764
+ }
765
+ return id;
766
+ }
767
+
768
+ return { resolver, folders };
769
+ }
770
+
771
+ /**
772
+ * Builds a compendium-source filename for a folder JSON document:
773
+ * `folder_Name_id.json` with non-alphanumeric runs replaced by
774
+ * underscores.
775
+ */
776
+ export function folderFilename(name, id) {
777
+ return (
778
+ `folder_${unidecode(name)}_${id}`.replace(/[^0-9a-zA-Z]+/g, "_") +
779
+ ".json"
780
+ );
781
+ }
782
+
783
+ /**
784
+ * Writes one JSON document per folder into `destDir`. `documentType`
785
+ * determines the folder's Foundry `type` field — `"Item"` for the items
786
+ * pack, `"JournalEntry"` for the journals pack.
787
+ */
788
+ export function writeFolderDocs(folders, stats, destDir, documentType) {
789
+ for (const folder of folders) {
790
+ const doc = {
791
+ name: folder.name,
792
+ sorting: "a",
793
+ folder: folder.parentFolderId || null,
794
+ type: documentType,
795
+ _id: folder.id,
796
+ sort: 0,
797
+ color: folder.color,
798
+ flags: folder.flags || {},
799
+ _stats: stats,
800
+ _key: `!folders!${folder.id}`,
801
+ };
802
+ const outPath = path.join(
803
+ destDir,
804
+ folderFilename(folder.name, folder.id),
805
+ );
806
+ fs.writeFileSync(outPath, JSON.stringify(doc, null, 2), "utf8");
807
+ }
808
+ log.info(`Emitted ${folders.length} folder document(s) to ${destDir}`);
809
+ }