@heroiclands/package-build 17.2.0 → 18.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 (152) hide show
  1. package/CHANGELOG.md +1711 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +225 -127
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +55 -83
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/manifest.mjs +43 -2
  74. package/package.json +18 -4
  75. package/release.mjs +62 -7
  76. package/sohl/actors.mjs +33 -487
  77. package/sohl/being-info.mjs +16 -7
  78. package/sohl/default-item-art.mjs +14 -3
  79. package/sohl/document-subtypes.mjs +16 -10
  80. package/sohl/item-builders.mjs +14 -5
  81. package/sohl/item-fields.mjs +68 -7
  82. package/sohl/items.mjs +44 -258
  83. package/sohl/note-schemas.mjs +2 -2
  84. package/types/content-config.d.mts +21 -42
  85. package/types/engine/actor-compiler.d.mts +204 -0
  86. package/types/engine/address-charset.d.mts +11 -8
  87. package/types/engine/address-diff.d.mts +53 -5
  88. package/types/engine/anchored-sections.d.mts +21 -0
  89. package/types/engine/anchors.d.mts +20 -0
  90. package/types/engine/base-compiler.d.mts +17 -17
  91. package/types/engine/bundle-notes.d.mts +173 -0
  92. package/types/engine/bundles.d.mts +60 -0
  93. package/types/engine/code-fences.d.mts +43 -0
  94. package/types/engine/compile-corpus.d.mts +32 -0
  95. package/types/engine/content-address.d.mts +205 -5
  96. package/types/engine/content-format-check.d.mts +6 -2
  97. package/types/engine/content-format.d.mts +57 -1
  98. package/types/engine/content-index.d.mts +54 -49
  99. package/types/engine/content-links.d.mts +52 -7
  100. package/types/engine/content-lint.d.mts +10 -1
  101. package/types/engine/content-package.d.mts +2 -1
  102. package/types/engine/content-tables.d.mts +21 -39
  103. package/types/engine/document-subtypes.d.mts +37 -3
  104. package/types/engine/field-spec.d.mts +76 -5
  105. package/types/engine/folder-notes.d.mts +159 -0
  106. package/types/engine/foreign-catalog.d.mts +53 -0
  107. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +2 -43
  108. package/types/engine/frontmatter-lint.d.mts +10 -2
  109. package/types/engine/frontmatter.d.mts +64 -0
  110. package/types/engine/generate.d.mts +38 -0
  111. package/types/engine/helpers.d.mts +94 -30
  112. package/types/engine/ids.d.mts +96 -0
  113. package/types/engine/index-records.d.mts +68 -0
  114. package/types/engine/index.d.mts +9 -3
  115. package/types/engine/item-compiler.d.mts +131 -0
  116. package/types/engine/journals.d.mts +47 -9
  117. package/types/engine/metadata-index.d.mts +226 -0
  118. package/types/engine/note-claims.d.mts +57 -11
  119. package/types/engine/note-ids.d.mts +38 -0
  120. package/types/engine/note-renames.d.mts +102 -0
  121. package/types/engine/note-vocabulary.d.mts +44 -8
  122. package/types/engine/retired-fields.d.mts +75 -0
  123. package/types/engine/scenes.d.mts +3 -2
  124. package/types/engine/schema-check.d.mts +25 -4
  125. package/types/engine/site-build.d.mts +4 -4
  126. package/types/engine/site-index.d.mts +1 -1
  127. package/types/engine/sql-tables.d.mts +185 -0
  128. package/types/engine/subtype-registry.d.mts +49 -0
  129. package/types/engine/system-block.d.mts +40 -1
  130. package/types/engine/systems.d.mts +106 -0
  131. package/types/engine/web-wikilinks.d.mts +4 -2
  132. package/types/engine/wikilink-syntax.d.mts +10 -3
  133. package/types/engine/wikilinks.d.mts +41 -13
  134. package/types/engine/yaml-lint.d.mts +107 -0
  135. package/types/hm3/actors.d.mts +48 -0
  136. package/types/hm3/default-item-art.d.mts +42 -0
  137. package/types/hm3/document-subtypes.d.mts +24 -0
  138. package/types/hm3/index.d.mts +7 -0
  139. package/types/hm3/item-builders.d.mts +11 -0
  140. package/types/hm3/item-fields.d.mts +12 -0
  141. package/types/hm3/items.d.mts +23 -0
  142. package/types/hm3/template-priority.d.mts +21 -0
  143. package/types/manifest.d.mts +22 -1
  144. package/types/release.d.mts +9 -4
  145. package/types/sohl/actors.d.mts +7 -74
  146. package/types/sohl/being-info.d.mts +8 -27
  147. package/types/sohl/default-item-art.d.mts +5 -3
  148. package/types/sohl/items.d.mts +17 -32
  149. package/engine/foreign-manifests.mjs +0 -126
  150. package/engine/kb-manifest.mjs +0 -490
  151. package/types/engine/foreign-manifests.d.mts +0 -43
  152. package/types/engine/kb-manifest.d.mts +0 -241
@@ -1,490 +0,0 @@
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
- * How many segments a canonical key has, and therefore how many the reader
94
- * below counts.
95
- *
96
- * Named rather than written as a literal because it is the *grammar*, not an
97
- * implementation detail of one function: it is the number a change to the
98
- * address form would move, and the thing a reader of that change has to find.
99
- *
100
- * @type {number}
101
- */
102
- export const CANONICAL_KEY_SEGMENTS = 3;
103
-
104
- /**
105
- * Reads a canonical key back into its parts.
106
- *
107
- * Parsing is plain positional counting: split on the separator, require
108
- * {@link CANONICAL_KEY_SEGMENTS} of them, and assign each position its field.
109
- * **The charset rule is what makes that sound** — every segment is
110
- * `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN` in `engine/address-charset.mjs`),
111
- * so the hyphen is purely a separator and the count alone determines every
112
- * field. That is enforced at each of the three sources rather than assumed of
113
- * the data: shortcodes by `content-lint.mjs` (#1397), `contentPackage` by
114
- * `defineConfig` (#59), and types are bare words. Were any of them free to
115
- * carry a hyphen, no amount of counting would recover the fields and the reader
116
- * would need a vocabulary to match against instead.
117
- *
118
- * **Nothing to read and nothing readable are different answers.** A key that
119
- * cannot be canonical — `harn-adventures-skill-melee`, four segments — yields
120
- * `null`, while an absent or blank input yields `undefined`. Both are falsy, so
121
- * every call site (all of which test the result for truthiness) is unaffected;
122
- * the distinction is there so a caller reporting "this key is unreadable" can
123
- * tell that it has a key to report about.
124
- *
125
- * @param {unknown} key - A canonical key, or nothing.
126
- * @returns {{package: string, type: string, shortcode: string}|null|undefined}
127
- * The parts; `null` when there is a string that is not in canonical form;
128
- * `undefined` when there is no key at all.
129
- */
130
- export function readCanonicalKey(key) {
131
- if (key == null || key === "") return undefined;
132
- const parts = String(key).split("-");
133
- if (parts.length !== CANONICAL_KEY_SEGMENTS) return null;
134
- const [pkg, type, shortcode] = parts;
135
- if (!pkg || !type || !shortcode) return null;
136
- return { package: pkg, type, shortcode };
137
- }
138
-
139
- /**
140
- * Manifest format version.
141
- *
142
- * Bumped to 2 by #1465: entries changed from a site-absolute `url` to a
143
- * package-relative `path`. The two shapes are indistinguishable to a naive
144
- * reader — prefixing a v1 `url` yields `/thalorna/thalorna/…`, which resolves,
145
- * renders, and 404s — so the version is what makes a stale vendored file an
146
- * error rather than a wrong link.
147
- *
148
- * Bumped to 4 by #1499: keys use the authored hyphen separator
149
- * (`sohl-affliction-aconite`) so a key *is* the address an author writes; an
150
- * item's documentation became an entry in its own right
151
- * (`sohl-docaffliction-aconite`) rather than a second field; and entries gained
152
- * `anchors`, mapping a note's named sections to the full UUID each compiled to.
153
- *
154
- * Bumped to 3 by #1499: keys became **canonical** — fully qualified
155
- * `package/type/shortcode` rather than `type/shortcode` — and entries gained the
156
- * Foundry `uuid` / `docUuid` beside the web `path`. A v2 key read as a v3 one
157
- * addresses a package named after a type, so again the version is what turns a
158
- * stale vendored file into an error.
159
- *
160
- * Bumped to 5 by #1516: `path` became optional, so a package that ships
161
- * compendiums and publishes no site can still publish the Foundry addresses of
162
- * its documents — the mirror of an entry that has a `path` and no `uuid`.
163
- */
164
- export const MANIFEST_VERSION = 5;
165
-
166
- /**
167
- * Every version this build can read, newest last.
168
- *
169
- * A version exists to stop a file whose values *read differently* from being
170
- * resolved anyway, and that is the only thing it is allowed to gate. Every bump
171
- * so far did change a reading — a v2 key read as a v4 one addresses a package
172
- * named after a type — so each dropped its predecessors. **v5 did not**: it
173
- * only permits an absent `path`, so every v4 value still means exactly what it
174
- * meant, and refusing v4 would make a purely relaxing change a flag day in
175
- * which every package must re-emit on the same afternoon or every build breaks
176
- * (#1516).
177
- *
178
- * The unsafe direction is unchanged and still hard-fails: an older consumer
179
- * meeting a newer file rejects it, because it cannot know what the new shape
180
- * permits. Widening is therefore always safe to do here first and adopt
181
- * elsewhere later.
182
- */
183
- export const READABLE_VERSIONS = Object.freeze([4, MANIFEST_VERSION]);
184
-
185
- /**
186
- * Where this build serves each package, keyed by package name.
187
- *
188
- * One line per package, and the only edit a relocation requires: point a
189
- * package at another path (`"/setting/thalorna/"`) or another origin
190
- * (`"https://thalorna.example.org/"`) and every inbound link into it follows.
191
- * A base is a prefix, so it must end in `/`.
192
- *
193
- * Only *foreign* packages are consulted — a package this build publishes is
194
- * authoritative in its own entries and never resolves through a manifest — but
195
- * every linkable package is listed, because which are foreign depends on the
196
- * consuming repository and this file is vendored into each of them.
197
- */
198
- export const PACKAGE_BASE = Object.freeze({
199
- sohl: "/sohl/",
200
- thalorna: "/thalorna/",
201
- });
202
-
203
- /**
204
- * Asserts a base is usable as a prefix and returns it.
205
- *
206
- * @param {string} base - The package base.
207
- * @param {string} what - What is being resolved, for the error message.
208
- * @returns {string} The base.
209
- */
210
- function checkBase(base, what) {
211
- if (typeof base !== "string" || !base.endsWith("/")) {
212
- throw new Error(`${what}: package base ${JSON.stringify(base)} must end in a slash`);
213
- }
214
- return base;
215
- }
216
-
217
- /**
218
- * The package-relative address a site-absolute URL records as.
219
- *
220
- * Strips the emitting package's own base, so what lands in the manifest says
221
- * *where in the package* a page is and nothing about where the package itself
222
- * is mounted. A URL outside the base is an error rather than a best effort: it
223
- * would record an address that silently resolves to the wrong place once a
224
- * consumer prefixes its own base.
225
- *
226
- * @param {string} url - The site-absolute URL the emitting build publishes at.
227
- * @param {string} base - That build's base for the package, e.g. `"/thalorna/"`.
228
- * @returns {string} The address relative to `base`, with no leading slash.
229
- */
230
- export function packageRelative(url, base) {
231
- checkBase(base, "packageRelative");
232
- if (typeof url !== "string" || !url.startsWith(base)) {
233
- throw new Error(
234
- `packageRelative: ${JSON.stringify(url)} does not sit under base ` +
235
- `${JSON.stringify(base)}`,
236
- );
237
- }
238
- return url.slice(base.length);
239
- }
240
-
241
- /**
242
- * The URL a package-relative address resolves to in this build.
243
- *
244
- * Plain concatenation, which is what makes an absolute-origin base work: a base
245
- * of `"https://thalorna.example.org/"` yields an absolute link, and one of
246
- * `"/thalorna/"` a root-relative one, with no other rule to keep in step.
247
- *
248
- * @param {string} rel - The package-relative address from a manifest entry.
249
- * @param {string} base - This build's base for that package.
250
- * @returns {string} The resolved URL.
251
- */
252
- export function resolvePackageUrl(rel, base) {
253
- checkBase(base, "resolvePackageUrl");
254
- if (typeof rel !== "string" || !rel || rel.startsWith("/")) {
255
- throw new Error(
256
- `resolvePackageUrl: ${JSON.stringify(rel)} is not a package-` + `relative address`,
257
- );
258
- }
259
- return `${base}${rel}`;
260
- }
261
-
262
- /**
263
- * Builds one package's manifest from the KB build's own entries.
264
- *
265
- * Only notes carrying a `shortcode` appear: the shortcode is the stable
266
- * identity another package addresses them by, and a note without one cannot be
267
- * the target of a cross-package link at all.
268
- *
269
- * @param {string} pkg - The package name, e.g. `"sohl"`.
270
- * @param {Array<object>} entries - KB entries (`{ fm, name, url }`).
271
- * @param {string} [base] - Where *this* build serves `pkg`, stripped from each
272
- * entry's URL so the recorded address is package-relative (#1465). Omitted,
273
- * this build publishes no web surface for the package and no entry carries a
274
- * `path` — see below.
275
- * @param {string} [foundryPackage] - The Foundry package this build ships the
276
- * compiled documents in. Given, each entry also carries the `uuid` /
277
- * `docUuid` a pack build resolves against; omitted, the manifest describes
278
- * the web surface only.
279
- * @returns {object} The manifest document.
280
- */
281
- export function buildManifest(pkg, entries, base, foundryPackage) {
282
- // A base is what a `path` is recorded relative to, so having none is
283
- // exactly the statement "this build publishes no pages for this package"
284
- // (#1516). Making it a package-level decision the caller states once — not
285
- // a per-note condition — is what stops a web-publishing package from
286
- // half-emitting, where the notes that quietly lost a `path` would degrade
287
- // to unlinked prose in every consumer with nothing erroring anywhere.
288
- const web = base != null;
289
- if (web) checkBase(base, `buildManifest(${pkg})`);
290
- const out = {};
291
- for (const e of entries) {
292
- const type = e.fm?.type;
293
- const shortcode = e.fm?.shortcode;
294
- if (!type || typeof shortcode !== "string" || !shortcode) continue;
295
- const entry = {
296
- // The web address, for consumers rendering pages. Absent for a
297
- // pack-only package, which has no page to point at — the mirror of
298
- // the `uuid` case below, and stating a `path` anyway would assert a
299
- // page that does not exist.
300
- ...(web ? { path: packageRelative(e.url, base) } : {}),
301
- name: e.name,
302
- };
303
- // The Foundry address, for consumers compiling packs rather than pages.
304
- // Supplied by the caller rather than derived here: only the build that
305
- // splits a note into pages knows its anchors, and a note that compiles
306
- // into no document has no UUID to state. Inventing one would assert a
307
- // target that does not exist, so an entry without one is normal and a
308
- // consumer must tolerate it.
309
- if (foundryPackage && e.uuid) entry.uuid = e.uuid;
310
- // The address of this item's documentation — a pointer to the entry
311
- // that owns that UUID, not a second copy of it.
312
- if (e.doc) entry.doc = e.doc;
313
- // A note's named sections, each mapped to the *whole* UUID it compiled
314
- // to. Whole, not a fragment appended to `uuid`: nothing owns a page
315
- // address, so there is no fact being restated, and an anchor is not
316
- // required to live inside its own entry. Publishing the complete link
317
- // also keeps the page-id hash out of the published contract entirely.
318
- if (e.anchors && Object.keys(e.anchors).length) entry.anchors = e.anchors;
319
- out[e.key ?? canonicalKey(pkg, type, shortcode)] = entry;
320
- }
321
- return {
322
- version: MANIFEST_VERSION,
323
- package: pkg,
324
- ...(foundryPackage ? { foundryPackage } : {}),
325
- // Sorted so the file is stable across builds and a diff shows only real
326
- // change — it is committed by whoever vendors it.
327
- entries: Object.fromEntries(
328
- Object.entries(out).sort(([a], [b]) =>
329
- a < b ? -1
330
- : a > b ? 1
331
- : 0,
332
- ),
333
- ),
334
- };
335
- }
336
-
337
- /**
338
- * Writes one manifest per package into `dir`.
339
- *
340
- * @param {Map<string, Array<object>>} entriesByPackage - Package → entries.
341
- * @param {string} dir - Output directory; created if absent.
342
- * @param {Record<string, string>} bases - Package → where *this* build serves
343
- * it, which is what each entry's address is recorded relative to. This is the
344
- * emitting build's own layout, not {@link PACKAGE_BASE}: a package's own site
345
- * commonly serves it at `"/"` while a consumer mounts it under a prefix.
346
- * @param {Record<string, string>} [foundryPackages] - Package → the Foundry
347
- * package shipping its documents. Only a package this build publishes can
348
- * have one, since the UUID names where *this* repository ships them.
349
- * @returns {Array<{ package: string, file: string, count: number }>} What was written.
350
- */
351
- export function writeManifests(entriesByPackage, dir, bases, foundryPackages) {
352
- fs.mkdirSync(dir, { recursive: true });
353
- const written = [];
354
- for (const [pkg, entries] of entriesByPackage) {
355
- const doc = buildManifest(pkg, entries, bases?.[pkg], foundryPackages?.[pkg]);
356
- const file = path.join(dir, `${pkg}.json`);
357
- fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
358
- written.push({
359
- package: pkg,
360
- file,
361
- count: Object.keys(doc.entries).length,
362
- });
363
- }
364
- return written;
365
- }
366
-
367
- /**
368
- * Loads vendored manifests for packages this build does not itself publish.
369
- *
370
- * A package built locally is skipped even if a manifest for it is present: the
371
- * live build is authoritative and a vendored copy of it can only be stale.
372
- *
373
- * Each entry's package-relative address is resolved against this build's base
374
- * for that package (#1465), so what the index holds is a usable `url` and every
375
- * consumer downstream is unchanged by the format.
376
- *
377
- * @param {string} dir - Directory of vendored `<package>.json` manifests.
378
- * @param {Iterable<string>} localPackages - Packages this build publishes.
379
- * @param {Record<string, string>} [bases] - Package → base to resolve against;
380
- * defaults to {@link PACKAGE_BASE}.
381
- * @returns {{ index: Map<string, object>, packages: Set<string>, stale: Array<object> }}
382
- * `index` maps the canonical `package-type-shortcode` → `{ url, name, uuid,
383
- * doc, anchors, type, package }`. Keys are globally unique, so this merges
384
- * directly into a local index with no prefixing and no separate lookup path.
385
- * `url` is `undefined` for an entry with no page (#1516) and `uuid` for one
386
- * that compiles into no document, so a caller must check the address it
387
- * intends to use rather than assume a hit carries it.
388
- */
389
- export function loadForeignManifests(dir, localPackages, bases = PACKAGE_BASE) {
390
- const local = new Set(localPackages);
391
- const index = new Map();
392
- const packages = new Set();
393
- const stale = [];
394
- let names;
395
- try {
396
- names = fs.readdirSync(dir);
397
- } catch {
398
- return { index, packages, stale };
399
- }
400
- for (const name of names) {
401
- if (!name.endsWith(".json")) continue;
402
- const pkg = path.basename(name, ".json");
403
- if (local.has(pkg)) continue;
404
- let doc;
405
- try {
406
- doc = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
407
- } catch (err) {
408
- stale.push({ package: pkg, reason: `unreadable: ${err.message}` });
409
- continue;
410
- }
411
- if (!READABLE_VERSIONS.includes(doc.version)) {
412
- // A v1 file is the site-absolute shape (#1465). Prefixing one of
413
- // its URLs would produce `/thalorna/thalorna/…` — a link that
414
- // resolves here and 404s for the reader — so the mismatch has to
415
- // stop the load rather than be resolved anyway.
416
- stale.push({
417
- package: pkg,
418
- reason:
419
- `manifest version ${doc.version}, expected one of ` +
420
- `${READABLE_VERSIONS.join(", ")}`,
421
- });
422
- continue;
423
- }
424
- const entriesIn = Object.entries(doc.entries ?? {});
425
- // A base is only needed to resolve a `path`, so a pack-only manifest —
426
- // Foundry addresses and no pages (#1516) — needs none, and demanding
427
- // one would make its documents uncitable from anywhere. Any entry that
428
- // does carry a `path` brings the requirement straight back: dropping
429
- // the package silently would turn every link into it back into an
430
- // unresolved address, which reads as a typo far from the cause.
431
- const base = bases?.[pkg];
432
- const needsBase = entriesIn.some(([, v]) => v?.path != null);
433
- if (needsBase && (typeof base !== "string" || !base)) {
434
- stale.push({
435
- package: pkg,
436
- reason: `no package base configured for "${pkg}" (PACKAGE_BASE in packages/content-build/engine/kb-manifest.mjs)`,
437
- });
438
- continue;
439
- }
440
- const resolved = [];
441
- try {
442
- for (const [key, v] of entriesIn) {
443
- // The type is read back out of the canonical key so a consumer
444
- // can recognise a foreign package's types as addresses at all.
445
- const type = readCanonicalKey(key)?.type;
446
- resolved.push([
447
- key,
448
- {
449
- name: v.name,
450
- // Absent for an entry with no page. A consumer must
451
- // tolerate that rather than invent an href, exactly as
452
- // it already tolerates an entry with no `uuid`.
453
- url: v.path == null ? undefined : resolvePackageUrl(v.path, base),
454
- uuid: v.uuid,
455
- doc: v.doc,
456
- anchors: v.anchors,
457
- type,
458
- },
459
- ]);
460
- }
461
- } catch (err) {
462
- stale.push({ package: pkg, reason: err.message });
463
- continue;
464
- }
465
- packages.add(pkg);
466
- for (const [key, v] of resolved) {
467
- // First writer wins, so two packages claiming one address cannot
468
- // make the build depend on directory order.
469
- if (!index.has(key)) index.set(key, { ...v, package: pkg });
470
- }
471
- }
472
- return { index, packages, stale };
473
- }
474
-
475
- /**
476
- * Whether every linkable package is accounted for, locally or by manifest.
477
- *
478
- * This is what gates the dead-link guard. It is deliberately derived from data
479
- * rather than set by a flag: the guard turns itself on the moment the last
480
- * missing manifest appears, instead of waiting for someone to remember.
481
- *
482
- * @param {Iterable<string>} localPackages - Packages this build publishes.
483
- * @param {Iterable<string>} manifestPackages - Packages loaded from manifests.
484
- * @returns {{ complete: boolean, missing: Array<string> }}
485
- */
486
- export function manifestsComplete(localPackages, manifestPackages) {
487
- const have = new Set([...localPackages, ...manifestPackages]);
488
- const missing = LINK_PACKAGES.filter((p) => !have.has(p));
489
- return { complete: missing.length === 0, missing };
490
- }
@@ -1,43 +0,0 @@
1
- /**
2
- * Every foreign package whose manifest entries a build cannot address.
3
- *
4
- * A package is reported only when it contributes entries and **none** of them
5
- * yields a readable canonical key — the total, silent failure described above.
6
- * Partial drift is deliberately not reported here: it resolves something, and
7
- * whatever it fails to resolve surfaces as an ordinary dead address, pointed at
8
- * the note that cites it. A package contributing no entries at all is likewise
9
- * not a finding; a pack-only package publishes no addressable pages by design
10
- * (#1516), and one being brought up publishes nothing yet.
11
- *
12
- * @param {Map<string, {package?: string}>} foreignIndex - `foreign.index` as
13
- * returned by `loadForeignManifests`, keyed by canonical key.
14
- * @returns {Array<{package: string, entries: number, sampleKey: string}>} One
15
- * finding per drifted package, in the order the index first names each.
16
- */
17
- export function unaddressableForeignPackages(foreignIndex: Map<string, {
18
- package?: string;
19
- }>): Array<{
20
- package: string;
21
- entries: number;
22
- sampleKey: string;
23
- }>;
24
- /**
25
- * One finding, in the standard `file:line:column: severity: message` form.
26
- *
27
- * The position is recovered by locating the offending key in the manifest text:
28
- * the finding is about a literal the reader can see in the file, so its position
29
- * is implicit rather than absent. When the file cannot be read, or the key is
30
- * not in it, the locator degrades to the file alone — a dropped field, never a
31
- * guessed `1:1` that would send the reader to the top of a 500 KB manifest for a
32
- * finding that is not there.
33
- *
34
- * @param {{package: string, entries: number, sampleKey: string}} finding - One
35
- * finding from {@link unaddressableForeignPackages}.
36
- * @param {string} manifestDir - The directory the manifests were loaded from.
37
- * @returns {string} The formatted diagnostic, path first on the line.
38
- */
39
- export function formatUnaddressableFinding(finding: {
40
- package: string;
41
- entries: number;
42
- sampleKey: string;
43
- }, manifestDir: string): string;