@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,473 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The cross-package link manifest (#1446).
16
+ *
17
+ * Each publishing package emits one file naming every note it publishes, keyed
18
+ * by the canonical `type/shortcode` address and valued with every address that
19
+ * note has: a `path` on the web, a `uuid` in Foundry.
20
+ * {@link loadForeignManifests} resolves each `path` into the `{ url, name }`
21
+ * the knowledgebase already uses as its own index value, so a foreign entry and
22
+ * a local one are interchangeable at the point of use.
23
+ *
24
+ * **Both addresses are optional, independently** (#1516). A note that publishes
25
+ * a page and compiles into no document has no `uuid`; a package that ships
26
+ * compendiums and publishes no site has no `path` on any entry. Neither is an
27
+ * error, and a consumer that cannot use the address it wanted must degrade —
28
+ * inventing the missing one asserts a target that does not exist, which is the
29
+ * silent dead link this whole format exists to prevent.
30
+ *
31
+ * The manifest exists to make one question decidable: when a link addresses
32
+ * `creature-grkrahk` and this build has never heard of it, is that a typo or a
33
+ * note belonging to another package? Before the manifest nothing in the syntax
34
+ * answered that, so the dead-link guard had to be left off for the hyphen form
35
+ * or correct content would fail the build (see `web-wikilinks.mjs`). With every
36
+ * package's manifest vendored, an address that resolves in none of them is a
37
+ * typo, and the guard can be restored.
38
+ *
39
+ * `kethira` is deliberately absent, on a **licensing** ground rather than a
40
+ * technical one: nothing may depend on it, because the module has to stay
41
+ * withdrawable (see that repository's `CLAUDE.md`), and a manifest edge
42
+ * pointing into it is exactly such a dependency. That it ships only packs is
43
+ * not the reason — since #1516 a pack-only package can publish a manifest.
44
+ *
45
+ * **An entry's address is relative to its own package's base** (#1465), never a
46
+ * site-absolute path. Where a package is *mounted* is the consumer's knowledge,
47
+ * held in {@link PACKAGE_BASE} and prefixed at resolve time — so moving a
48
+ * package to another path or origin is one string per consumer rather than a
49
+ * regenerated manifest, and an inbound link survives the move. A path recorded
50
+ * in the manifest would not: it resolves, emits an `href`, and 404s, which is
51
+ * the silent failure the manifest exists to end.
52
+ */
53
+
54
+ import fs from "node:fs";
55
+ import path from "node:path";
56
+
57
+ /**
58
+ * Packages that publish a manifest and therefore exchange addresses.
59
+ *
60
+ * The guard in {@link manifestsComplete} stays off until every one of these is
61
+ * accounted for, so adding a package here without also publishing its manifest
62
+ * relaxes the build rather than breaking it.
63
+ */
64
+ export const LINK_PACKAGES = Object.freeze(["sohl", "thalorna"]);
65
+
66
+ /**
67
+ * The **canonical** address of a note: fully qualified, one spelling per
68
+ * document, and globally unique.
69
+ *
70
+ * The written form of a link may omit the package (`[[skill-lang]]`), which
71
+ * defaults it to the citing note's own. Everything internal — index keys,
72
+ * manifest keys, every lookup — uses this instead, so no consumer has to know
73
+ * what a short form defaulted to.
74
+ *
75
+ * Global uniqueness is what lets a foreign manifest merge straight into a local
76
+ * index: the keys cannot collide by accident, so a key already present on merge
77
+ * is a real conflict rather than an artefact of two packages sharing a
78
+ * namespace. `(type, shortcode)` alone is unique only *within* a package, and
79
+ * two independently authored packages reaching for the same short string is a
80
+ * matter of time (#1499).
81
+ *
82
+ * @param {string} pkg - The owning **content** package (`sohl`, `thalorna`) —
83
+ * not the Foundry package, which varies per compilation target.
84
+ * @param {string} type - The note's `type`.
85
+ * @param {string} shortcode - The note's `shortcode`.
86
+ * @returns {string} `package/type/shortcode`, lowercased.
87
+ */
88
+ export function canonicalKey(pkg, type, shortcode) {
89
+ return `${pkg}-${type}-${shortcode}`.toLowerCase();
90
+ }
91
+
92
+ /**
93
+ * Reads a canonical key back into its parts.
94
+ *
95
+ * Unambiguous because no package, type or shortcode contains a hyphen — types
96
+ * are bare words and shortcodes are `^[A-Za-z0-9]+$` (#1397).
97
+ *
98
+ * @param {string} key - A canonical key.
99
+ * @returns {{package: string, type: string, shortcode: string}|null} The parts,
100
+ * or `null` when the key is not in canonical form.
101
+ */
102
+ export function readCanonicalKey(key) {
103
+ const parts = String(key).split("-");
104
+ if (parts.length !== 3) return null;
105
+ const [pkg, type, shortcode] = parts;
106
+ if (!pkg || !type || !shortcode) return null;
107
+ return { package: pkg, type, shortcode };
108
+ }
109
+
110
+ /**
111
+ * Manifest format version.
112
+ *
113
+ * Bumped to 2 by #1465: entries changed from a site-absolute `url` to a
114
+ * package-relative `path`. The two shapes are indistinguishable to a naive
115
+ * reader — prefixing a v1 `url` yields `/thalorna/thalorna/…`, which resolves,
116
+ * renders, and 404s — so the version is what makes a stale vendored file an
117
+ * error rather than a wrong link.
118
+ *
119
+ * Bumped to 4 by #1499: keys use the authored hyphen separator
120
+ * (`sohl-affliction-aconite`) so a key *is* the address an author writes; an
121
+ * item's documentation became an entry in its own right
122
+ * (`sohl-docaffliction-aconite`) rather than a second field; and entries gained
123
+ * `anchors`, mapping a note's named sections to the full UUID each compiled to.
124
+ *
125
+ * Bumped to 3 by #1499: keys became **canonical** — fully qualified
126
+ * `package/type/shortcode` rather than `type/shortcode` — and entries gained the
127
+ * Foundry `uuid` / `docUuid` beside the web `path`. A v2 key read as a v3 one
128
+ * addresses a package named after a type, so again the version is what turns a
129
+ * stale vendored file into an error.
130
+ *
131
+ * Bumped to 5 by #1516: `path` became optional, so a package that ships
132
+ * compendiums and publishes no site can still publish the Foundry addresses of
133
+ * its documents — the mirror of an entry that has a `path` and no `uuid`.
134
+ */
135
+ export const MANIFEST_VERSION = 5;
136
+
137
+ /**
138
+ * Every version this build can read, newest last.
139
+ *
140
+ * A version exists to stop a file whose values *read differently* from being
141
+ * resolved anyway, and that is the only thing it is allowed to gate. Every bump
142
+ * so far did change a reading — a v2 key read as a v4 one addresses a package
143
+ * named after a type — so each dropped its predecessors. **v5 did not**: it
144
+ * only permits an absent `path`, so every v4 value still means exactly what it
145
+ * meant, and refusing v4 would make a purely relaxing change a flag day in
146
+ * which every package must re-emit on the same afternoon or every build breaks
147
+ * (#1516).
148
+ *
149
+ * The unsafe direction is unchanged and still hard-fails: an older consumer
150
+ * meeting a newer file rejects it, because it cannot know what the new shape
151
+ * permits. Widening is therefore always safe to do here first and adopt
152
+ * elsewhere later.
153
+ */
154
+ export const READABLE_VERSIONS = Object.freeze([4, MANIFEST_VERSION]);
155
+
156
+ /**
157
+ * Where this build serves each package, keyed by package name.
158
+ *
159
+ * One line per package, and the only edit a relocation requires: point a
160
+ * package at another path (`"/setting/thalorna/"`) or another origin
161
+ * (`"https://thalorna.example.org/"`) and every inbound link into it follows.
162
+ * A base is a prefix, so it must end in `/`.
163
+ *
164
+ * Only *foreign* packages are consulted — a package this build publishes is
165
+ * authoritative in its own entries and never resolves through a manifest — but
166
+ * every linkable package is listed, because which are foreign depends on the
167
+ * consuming repository and this file is vendored into each of them.
168
+ */
169
+ export const PACKAGE_BASE = Object.freeze({
170
+ sohl: "/sohl/",
171
+ thalorna: "/thalorna/",
172
+ });
173
+
174
+ /**
175
+ * Asserts a base is usable as a prefix and returns it.
176
+ *
177
+ * @param {string} base - The package base.
178
+ * @param {string} what - What is being resolved, for the error message.
179
+ * @returns {string} The base.
180
+ */
181
+ function checkBase(base, what) {
182
+ if (typeof base !== "string" || !base.endsWith("/")) {
183
+ throw new Error(
184
+ `${what}: package base ${JSON.stringify(base)} must end in a slash`,
185
+ );
186
+ }
187
+ return base;
188
+ }
189
+
190
+ /**
191
+ * The package-relative address a site-absolute URL records as.
192
+ *
193
+ * Strips the emitting package's own base, so what lands in the manifest says
194
+ * *where in the package* a page is and nothing about where the package itself
195
+ * is mounted. A URL outside the base is an error rather than a best effort: it
196
+ * would record an address that silently resolves to the wrong place once a
197
+ * consumer prefixes its own base.
198
+ *
199
+ * @param {string} url - The site-absolute URL the emitting build publishes at.
200
+ * @param {string} base - That build's base for the package, e.g. `"/thalorna/"`.
201
+ * @returns {string} The address relative to `base`, with no leading slash.
202
+ */
203
+ export function packageRelative(url, base) {
204
+ checkBase(base, "packageRelative");
205
+ if (typeof url !== "string" || !url.startsWith(base)) {
206
+ throw new Error(
207
+ `packageRelative: ${JSON.stringify(url)} does not sit under base ` +
208
+ `${JSON.stringify(base)}`,
209
+ );
210
+ }
211
+ return url.slice(base.length);
212
+ }
213
+
214
+ /**
215
+ * The URL a package-relative address resolves to in this build.
216
+ *
217
+ * Plain concatenation, which is what makes an absolute-origin base work: a base
218
+ * of `"https://thalorna.example.org/"` yields an absolute link, and one of
219
+ * `"/thalorna/"` a root-relative one, with no other rule to keep in step.
220
+ *
221
+ * @param {string} rel - The package-relative address from a manifest entry.
222
+ * @param {string} base - This build's base for that package.
223
+ * @returns {string} The resolved URL.
224
+ */
225
+ export function resolvePackageUrl(rel, base) {
226
+ checkBase(base, "resolvePackageUrl");
227
+ if (typeof rel !== "string" || !rel || rel.startsWith("/")) {
228
+ throw new Error(
229
+ `resolvePackageUrl: ${JSON.stringify(rel)} is not a package-` +
230
+ `relative address`,
231
+ );
232
+ }
233
+ return `${base}${rel}`;
234
+ }
235
+
236
+ /**
237
+ * Builds one package's manifest from the KB build's own entries.
238
+ *
239
+ * Only notes carrying a `shortcode` appear: the shortcode is the stable
240
+ * identity another package addresses them by, and a note without one cannot be
241
+ * the target of a cross-package link at all.
242
+ *
243
+ * @param {string} pkg - The package name, e.g. `"sohl"`.
244
+ * @param {Array<object>} entries - KB entries (`{ fm, name, url }`).
245
+ * @param {string} [base] - Where *this* build serves `pkg`, stripped from each
246
+ * entry's URL so the recorded address is package-relative (#1465). Omitted,
247
+ * this build publishes no web surface for the package and no entry carries a
248
+ * `path` — see below.
249
+ * @param {string} [foundryPackage] - The Foundry package this build ships the
250
+ * compiled documents in. Given, each entry also carries the `uuid` /
251
+ * `docUuid` a pack build resolves against; omitted, the manifest describes
252
+ * the web surface only.
253
+ * @returns {object} The manifest document.
254
+ */
255
+ export function buildManifest(pkg, entries, base, foundryPackage) {
256
+ // A base is what a `path` is recorded relative to, so having none is
257
+ // exactly the statement "this build publishes no pages for this package"
258
+ // (#1516). Making it a package-level decision the caller states once — not
259
+ // a per-note condition — is what stops a web-publishing package from
260
+ // half-emitting, where the notes that quietly lost a `path` would degrade
261
+ // to unlinked prose in every consumer with nothing erroring anywhere.
262
+ const web = base != null;
263
+ if (web) checkBase(base, `buildManifest(${pkg})`);
264
+ const out = {};
265
+ for (const e of entries) {
266
+ const type = e.fm?.type;
267
+ const shortcode = e.fm?.shortcode;
268
+ if (!type || typeof shortcode !== "string" || !shortcode) continue;
269
+ const entry = {
270
+ // The web address, for consumers rendering pages. Absent for a
271
+ // pack-only package, which has no page to point at — the mirror of
272
+ // the `uuid` case below, and stating a `path` anyway would assert a
273
+ // page that does not exist.
274
+ ...(web ? { path: packageRelative(e.url, base) } : {}),
275
+ name: e.name,
276
+ };
277
+ // The Foundry address, for consumers compiling packs rather than pages.
278
+ // Supplied by the caller rather than derived here: only the build that
279
+ // splits a note into pages knows its anchors, and a note that compiles
280
+ // into no document has no UUID to state. Inventing one would assert a
281
+ // target that does not exist, so an entry without one is normal and a
282
+ // consumer must tolerate it.
283
+ if (foundryPackage && e.uuid) entry.uuid = e.uuid;
284
+ // The address of this item's documentation — a pointer to the entry
285
+ // that owns that UUID, not a second copy of it.
286
+ if (e.doc) entry.doc = e.doc;
287
+ // A note's named sections, each mapped to the *whole* UUID it compiled
288
+ // to. Whole, not a fragment appended to `uuid`: nothing owns a page
289
+ // address, so there is no fact being restated, and an anchor is not
290
+ // required to live inside its own entry. Publishing the complete link
291
+ // also keeps the page-id hash out of the published contract entirely.
292
+ if (e.anchors && Object.keys(e.anchors).length)
293
+ entry.anchors = e.anchors;
294
+ out[e.key ?? canonicalKey(pkg, type, shortcode)] = entry;
295
+ }
296
+ return {
297
+ version: MANIFEST_VERSION,
298
+ package: pkg,
299
+ ...(foundryPackage ? { foundryPackage } : {}),
300
+ // Sorted so the file is stable across builds and a diff shows only real
301
+ // change — it is committed by whoever vendors it.
302
+ entries: Object.fromEntries(
303
+ Object.entries(out).sort(([a], [b]) =>
304
+ a < b ? -1
305
+ : a > b ? 1
306
+ : 0,
307
+ ),
308
+ ),
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Writes one manifest per package into `dir`.
314
+ *
315
+ * @param {Map<string, Array<object>>} entriesByPackage - Package → entries.
316
+ * @param {string} dir - Output directory; created if absent.
317
+ * @param {Record<string, string>} bases - Package → where *this* build serves
318
+ * it, which is what each entry's address is recorded relative to. This is the
319
+ * emitting build's own layout, not {@link PACKAGE_BASE}: a package's own site
320
+ * commonly serves it at `"/"` while a consumer mounts it under a prefix.
321
+ * @param {Record<string, string>} [foundryPackages] - Package → the Foundry
322
+ * package shipping its documents. Only a package this build publishes can
323
+ * have one, since the UUID names where *this* repository ships them.
324
+ * @returns {Array<{ package: string, file: string, count: number }>} What was written.
325
+ */
326
+ export function writeManifests(entriesByPackage, dir, bases, foundryPackages) {
327
+ fs.mkdirSync(dir, { recursive: true });
328
+ const written = [];
329
+ for (const [pkg, entries] of entriesByPackage) {
330
+ const doc = buildManifest(
331
+ pkg,
332
+ entries,
333
+ bases?.[pkg],
334
+ foundryPackages?.[pkg],
335
+ );
336
+ const file = path.join(dir, `${pkg}.json`);
337
+ fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
338
+ written.push({
339
+ package: pkg,
340
+ file,
341
+ count: Object.keys(doc.entries).length,
342
+ });
343
+ }
344
+ return written;
345
+ }
346
+
347
+ /**
348
+ * Loads vendored manifests for packages this build does not itself publish.
349
+ *
350
+ * A package built locally is skipped even if a manifest for it is present: the
351
+ * live build is authoritative and a vendored copy of it can only be stale.
352
+ *
353
+ * Each entry's package-relative address is resolved against this build's base
354
+ * for that package (#1465), so what the index holds is a usable `url` and every
355
+ * consumer downstream is unchanged by the format.
356
+ *
357
+ * @param {string} dir - Directory of vendored `<package>.json` manifests.
358
+ * @param {Iterable<string>} localPackages - Packages this build publishes.
359
+ * @param {Record<string, string>} [bases] - Package → base to resolve against;
360
+ * defaults to {@link PACKAGE_BASE}.
361
+ * @returns {{ index: Map<string, object>, packages: Set<string>, stale: Array<object> }}
362
+ * `index` maps the canonical `package-type-shortcode` → `{ url, name, uuid,
363
+ * doc, anchors, type, package }`. Keys are globally unique, so this merges
364
+ * directly into a local index with no prefixing and no separate lookup path.
365
+ * `url` is `undefined` for an entry with no page (#1516) and `uuid` for one
366
+ * that compiles into no document, so a caller must check the address it
367
+ * intends to use rather than assume a hit carries it.
368
+ */
369
+ export function loadForeignManifests(dir, localPackages, bases = PACKAGE_BASE) {
370
+ const local = new Set(localPackages);
371
+ const index = new Map();
372
+ const packages = new Set();
373
+ const stale = [];
374
+ let names;
375
+ try {
376
+ names = fs.readdirSync(dir);
377
+ } catch {
378
+ return { index, packages, stale };
379
+ }
380
+ for (const name of names) {
381
+ if (!name.endsWith(".json")) continue;
382
+ const pkg = path.basename(name, ".json");
383
+ if (local.has(pkg)) continue;
384
+ let doc;
385
+ try {
386
+ doc = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
387
+ } catch (err) {
388
+ stale.push({ package: pkg, reason: `unreadable: ${err.message}` });
389
+ continue;
390
+ }
391
+ if (!READABLE_VERSIONS.includes(doc.version)) {
392
+ // A v1 file is the site-absolute shape (#1465). Prefixing one of
393
+ // its URLs would produce `/thalorna/thalorna/…` — a link that
394
+ // resolves here and 404s for the reader — so the mismatch has to
395
+ // stop the load rather than be resolved anyway.
396
+ stale.push({
397
+ package: pkg,
398
+ reason:
399
+ `manifest version ${doc.version}, expected one of ` +
400
+ `${READABLE_VERSIONS.join(", ")}`,
401
+ });
402
+ continue;
403
+ }
404
+ const entriesIn = Object.entries(doc.entries ?? {});
405
+ // A base is only needed to resolve a `path`, so a pack-only manifest —
406
+ // Foundry addresses and no pages (#1516) — needs none, and demanding
407
+ // one would make its documents uncitable from anywhere. Any entry that
408
+ // does carry a `path` brings the requirement straight back: dropping
409
+ // the package silently would turn every link into it back into an
410
+ // unresolved address, which reads as a typo far from the cause.
411
+ const base = bases?.[pkg];
412
+ const needsBase = entriesIn.some(([, v]) => v?.path != null);
413
+ if (needsBase && (typeof base !== "string" || !base)) {
414
+ stale.push({
415
+ package: pkg,
416
+ reason: `no package base configured for "${pkg}" (PACKAGE_BASE in packages/content-build/engine/kb-manifest.mjs)`,
417
+ });
418
+ continue;
419
+ }
420
+ const resolved = [];
421
+ try {
422
+ for (const [key, v] of entriesIn) {
423
+ // The type is read back out of the canonical key so a consumer
424
+ // can recognise a foreign package's types as addresses at all.
425
+ const type = readCanonicalKey(key)?.type;
426
+ resolved.push([
427
+ key,
428
+ {
429
+ name: v.name,
430
+ // Absent for an entry with no page. A consumer must
431
+ // tolerate that rather than invent an href, exactly as
432
+ // it already tolerates an entry with no `uuid`.
433
+ url:
434
+ v.path == null ?
435
+ undefined
436
+ : resolvePackageUrl(v.path, base),
437
+ uuid: v.uuid,
438
+ doc: v.doc,
439
+ anchors: v.anchors,
440
+ type,
441
+ },
442
+ ]);
443
+ }
444
+ } catch (err) {
445
+ stale.push({ package: pkg, reason: err.message });
446
+ continue;
447
+ }
448
+ packages.add(pkg);
449
+ for (const [key, v] of resolved) {
450
+ // First writer wins, so two packages claiming one address cannot
451
+ // make the build depend on directory order.
452
+ if (!index.has(key)) index.set(key, { ...v, package: pkg });
453
+ }
454
+ }
455
+ return { index, packages, stale };
456
+ }
457
+
458
+ /**
459
+ * Whether every linkable package is accounted for, locally or by manifest.
460
+ *
461
+ * This is what gates the dead-link guard. It is deliberately derived from data
462
+ * rather than set by a flag: the guard turns itself on the moment the last
463
+ * missing manifest appears, instead of waiting for someone to remember.
464
+ *
465
+ * @param {Iterable<string>} localPackages - Packages this build publishes.
466
+ * @param {Iterable<string>} manifestPackages - Packages loaded from manifests.
467
+ * @returns {{ complete: boolean, missing: Array<string> }}
468
+ */
469
+ export function manifestsComplete(localPackages, manifestPackages) {
470
+ const have = new Set([...localPackages, ...manifestPackages]);
471
+ const missing = LINK_PACKAGES.filter((p) => !have.has(p));
472
+ return { complete: missing.length === 0, missing };
473
+ }