@heroiclands/package-build 22.1.1 → 22.3.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 (42) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/CONTENT.md +27 -32
  3. package/bin/content-build.mjs +0 -3
  4. package/bin/package-build.mjs +9 -26
  5. package/config.mjs +7 -2
  6. package/content-config.mjs +73 -41
  7. package/docs/api.md +26 -25
  8. package/docs/commands.md +22 -22
  9. package/docs/configuration.md +78 -46
  10. package/docs/content-format.md +36 -6
  11. package/engine/foundry-entries.mjs +12 -1
  12. package/engine/frontmatter-lint.mjs +2 -3
  13. package/engine/helpers.mjs +4 -1
  14. package/engine/ids.mjs +12 -0
  15. package/engine/note-claims.mjs +21 -0
  16. package/engine/pack-config.mjs +14 -1
  17. package/engine/pack-router.mjs +82 -5
  18. package/engine/pdf-build.mjs +2 -2
  19. package/engine/retired-fields.mjs +2 -3
  20. package/engine/schema-check.mjs +6 -6
  21. package/engine/site-build.mjs +59 -218
  22. package/engine/site-config.mjs +22 -22
  23. package/engine/site-index.mjs +18 -62
  24. package/engine/web-wikilinks.mjs +2 -16
  25. package/engine/wikilinks.mjs +35 -14
  26. package/manifest.mjs +16 -10
  27. package/package.json +1 -1
  28. package/release.mjs +41 -8
  29. package/sohl/kb-passes.mjs +14 -85
  30. package/types/config.d.mts +17 -0
  31. package/types/content-config.d.mts +6 -6
  32. package/types/engine/ids.d.mts +11 -0
  33. package/types/engine/pack-router.d.mts +20 -2
  34. package/types/engine/schema-check.d.mts +5 -5
  35. package/types/engine/site-build.d.mts +12 -62
  36. package/types/engine/site-config.d.mts +6 -8
  37. package/types/engine/site-index.d.mts +3 -28
  38. package/types/engine/web-wikilinks.d.mts +1 -1
  39. package/types/engine/wikilinks.d.mts +6 -3
  40. package/types/manifest.d.mts +5 -3
  41. package/types/release.d.mts +7 -6
  42. package/types/sohl/kb-passes.d.mts +5 -36
@@ -65,20 +65,14 @@ import { isDraftNote } from "./note-vocabulary.mjs";
65
65
  *
66
66
  * @typedef {object} SiteEntry
67
67
  * @property {string} kind `"content"` for a note compiled from the content
68
- * tree, anything else for a page that carries no
69
- * `type`/`shortcode` (a developer doc, say). Only
70
- * content entries take part in type-scoped indexing.
68
+ * tree. Only content entries take part in
69
+ * type-scoped indexing; anything else is carried
70
+ * through unindexed.
71
71
  * @property {object} fm The note's frontmatter.
72
72
  * @property {string} name Display name.
73
73
  * @property {string} slug URL segment.
74
- * @property {string} [sec] The Hugo section a **tree** page is filed under, and
75
- * the first segment of the `<sec>/<slug>` address it
76
- * is reachable by. A content page has none: it is
77
- * addressed by `(type, shortcode)` and emitted flat.
78
74
  * @property {string} base Source file's basename, e.g. `Climbing.md`.
79
75
  * @property {string} url The page's published address.
80
- * @property {boolean} [isReadme] Whether a tree page is its directory's
81
- * landing.
82
76
  */
83
77
 
84
78
  /**
@@ -94,23 +88,18 @@ import { isDraftNote } from "./note-vocabulary.mjs";
94
88
  * from `index`.
95
89
  * @property {Set<string>} contentTypes Every type the resolver should read as
96
90
  * an address qualifier, local and foreign.
97
- * @property {Set<string>} sections Section names, lowercased.
98
91
  * @property {Map<string, {name: string, url: string, subType?: string}>} refIndex
99
92
  * `type:shortcode` → page, for callers
100
93
  * resolving embedded references (a
101
94
  * being's items, say).
102
- * @property {{key: string, package: string}[]} conflicts Addresses claimed by
103
- * more than one package. Non-empty is a
104
- * build failure; the caller reports it.
105
95
  */
106
96
 
107
97
  /**
108
98
  * Merge the packages this build does not publish into the local index.
109
99
  *
110
100
  * Every canonical key is globally unique, so a foreign manifest merges straight
111
- * in — one map, one lookup, no precedence rule. A key already present is a
112
- * genuine conflict: two packages claiming one address, which is the case the
113
- * canonical form exists to make detectable.
101
+ * in — one map, one lookup, no precedence rule. It runs before the local pass
102
+ * writes a single key, so a local page always ends up owning its own address.
114
103
  *
115
104
  * The short `type/shortcode` form is merged too, because a bare `[[doc-xyz]]`
116
105
  * carries no package and must still find a foreign note when exactly one
@@ -122,22 +111,15 @@ import { isDraftNote } from "./note-vocabulary.mjs";
122
111
  *
123
112
  * @param {Map<string, object>} index - The local index, mutated.
124
113
  * @param {Map<string, {package: string, type?: string}>} foreignIndex - Merged in.
125
- * @returns {{conflicts: {key: string, package: string}[],
126
- * ambiguous: Set<string>}} The addresses two packages both claim outright,
127
- * and the short `type/shortcode` forms two foreign packages claim — those are
128
- * left out of the index, so a resolver can say *ambiguous* rather than
129
- * *nothing answers*.
114
+ * @returns {{ambiguous: Set<string>}} The short `type/shortcode` forms two
115
+ * foreign packages claim those are left out of the index, so a resolver
116
+ * can say *ambiguous* rather than *nothing answers*.
130
117
  */
131
118
  function mergeForeign(index, foreignIndex) {
132
- const conflicts = [];
133
119
  const short = new Map();
134
120
  const ambiguous = new Set();
135
121
 
136
122
  for (const [key, value] of foreignIndex) {
137
- if (index.has(key)) {
138
- conflicts.push({ key, package: value.package });
139
- continue;
140
- }
141
123
  index.set(key, value);
142
124
 
143
125
  const parts = readCanonicalKey(key);
@@ -154,7 +136,7 @@ function mergeForeign(index, foreignIndex) {
154
136
  for (const [key, value] of short) {
155
137
  if (!index.has(key)) index.set(key, value);
156
138
  }
157
- return { conflicts, ambiguous };
139
+ return { ambiguous };
158
140
  }
159
141
 
160
142
  /**
@@ -178,7 +160,6 @@ export function buildSiteIndex(
178
160
  ) {
179
161
  const index = new Map();
180
162
  const contentTypes = new Set();
181
- const sections = new Set();
182
163
  const refIndex = new Map();
183
164
  // Every package an address may name: this build's own, plus every one a
184
165
  // vendored manifest speaks for. Without it `readQualifier` cannot see the
@@ -187,30 +168,12 @@ export function buildSiteIndex(
187
168
  const ownPackage = contentPackage();
188
169
  const packages = new Set(ownPackage ? [ownPackage] : []);
189
170
 
190
- // `section/slug` is unique by construction, and is now a **tree** page's
191
- // address: a `trees` entry keeps its source layout below a named section,
192
- // so `dev-docs/testing` is how one is cited. A content page carries no
193
- // section at all and is addressed by `(type, shortcode)` below
194
- // indexing it here as well would have written `weapongear/weapongear-dagger`,
195
- // a key no author could reasonably write.
196
- //
197
- // A page's name, filename and bare slug were indexed here too, as
198
- // collision-aware fallbacks the bare `[[Name]]` form looked up; that form is
199
- // retired and nothing consults them, so they are gone and with them the rule
200
- // that two pages of a type may not share a name.
201
- for (const e of entries) {
202
- if (typeof e.sec !== "string" || !e.sec) continue;
203
- sections.add(e.sec.toLowerCase());
204
- // `draft` rides on every key a page is addressable by, because a link
205
- // into a draft note renders marked whichever of them the author wrote.
206
- // It decides nothing about resolution: the page is indexed and
207
- // published as any other.
208
- index.set(`${e.sec}/${e.slug}`.toLowerCase(), {
209
- url: e.url,
210
- name: e.name,
211
- draft: isDraftNote(e.fm),
212
- });
213
- }
171
+ // A page is addressed by `(type, shortcode)` and nothing else. Its name,
172
+ // filename and bare slug are not keys: the bare `[[Name]]` form that
173
+ // looked them up is retired, and with it the rule that two pages of a
174
+ // type may not share a name. A page carries no section either a
175
+ // section is a listing the configuration declares, not part of any
176
+ // address.
214
177
 
215
178
  // A foreign package may use a type this build has never seen. Seeding those
216
179
  // is what lets the resolver recognise `polity-xyz` as an address at all —
@@ -226,15 +189,11 @@ export function buildSiteIndex(
226
189
  // the local packages, so a manifest should never carry one — this is what
227
190
  // makes that a belt-and-braces rather than the only thing standing between
228
191
  // a stale vendored manifest and a shadowed local page.
229
- //
230
- // The corollary is that a conflict can only be reported against the keys
231
- // that exist at this point — the addressing ones, `section/slug` and the
232
- // bare fallbacks — which is precisely the overlap worth refusing.
233
- const { conflicts, ambiguous } = mergeForeign(index, foreignIndex);
192
+ const { ambiguous } = mergeForeign(index, foreignIndex);
234
193
 
235
194
  for (const e of entries) {
236
- // A page with no type or shortcode a developer doc — is addressable
237
- // by section and name, and takes no part in type-scoped indexing.
195
+ // Only a content note is addressable; anything else is carried
196
+ // through and takes no part in type-scoped indexing.
238
197
  if (e.kind !== "content") continue;
239
198
  const type = String(e.fm.type).toLowerCase();
240
199
  contentTypes.add(type);
@@ -303,11 +262,9 @@ export function buildSiteIndex(
303
262
  index,
304
263
  ambiguous,
305
264
  contentTypes,
306
- sections,
307
265
  packages,
308
266
  noIndexPackages,
309
267
  refIndex,
310
- conflicts,
311
268
  };
312
269
  }
313
270
 
@@ -352,7 +309,6 @@ export function wikiContext(
352
309
  // a URL and a name, an asset's carries the path to a file.
353
310
  assets,
354
311
  collide: built.ambiguous,
355
- sections: built.sections,
356
312
  contentTypes: built.contentTypes,
357
313
  packages: built.packages,
358
314
  noIndexPackages: built.noIndexPackages,
@@ -312,7 +312,7 @@ function isPlainMap(value) {
312
312
  * for the website, and what the book reads its staging list out of.
313
313
  *
314
314
  * @param {string} body - The markdown body.
315
- * @param {object} ctx - `{ index, assets, collide, sections, contentTypes,
315
+ * @param {object} ctx - `{ index, assets, collide, contentTypes,
316
316
  * packages, noIndexPackages, foreign, type, errors, src, file }`.
317
317
  * `packages` is every package an address may name, without which the leading
318
318
  * package segment of a canonical address reads as an unknown type;
@@ -415,10 +415,6 @@ export function resolveWebWikilinks(body, ctx) {
415
415
  const rawKey = target.toLowerCase();
416
416
  const hit =
417
417
  lookupRead(ctx.index, read, ctx.contentPackage) ??
418
- // `section/slug` is the site's own address for a page, and it is in
419
- // the same map. Admitted only when the target carries a slash, so
420
- // a page's bare slug cannot answer for an address.
421
- (rawKey.includes("/") ? ctx.index.get(rawKey) : undefined) ??
422
418
  // A manifest entry carries the same `{ url, name }` shape as a
423
419
  // local one, so a cross-package hit needs no special case
424
420
  // below. Local wins: a live build is authoritative and a vendored
@@ -449,16 +445,6 @@ export function resolveWebWikilinks(body, ctx) {
449
445
  return hit.draft ? draftLink(link) : link;
450
446
  }
451
447
 
452
- const slash = target.indexOf("/");
453
- const prefix = slash === -1 ? null : target.slice(0, slash).toLowerCase();
454
- // A slash-qualified target whose prefix is a real KB **section** is an
455
- // address in the site's own `section/slug` space, which the lookup
456
- // above already consulted. It parses as no `type/shortcode`, but it did
457
- // address something and nothing answered — so it is unresolved, not
458
- // unaddressable. (A prefix that is a content *type* never reaches here:
459
- // it parses as an address.)
460
- const siteAddress = prefix !== null && ctx.sections.has(prefix);
461
-
462
448
  // **An address resolving nowhere is a failure, unconditionally**.
463
449
  //
464
450
  // It was gated on a manifest-completeness check — while any linkable package was
@@ -485,7 +471,7 @@ export function resolveWebWikilinks(body, ctx) {
485
471
  ctx.collide?.has(collideKey) ? "ambiguous"
486
472
  // "It parsed as an address" is a property of the parse, not of
487
473
  // a key: a partial address has no single key to be non-null.
488
- : (read && !read.reason) || siteAddress ? "unresolved"
474
+ : read && !read.reason ? "unresolved"
489
475
  : read?.reason === "unknown-type" ? "unknown-type"
490
476
  : read?.reason === "no-content-index" ? "no-content-index"
491
477
  // Every link is an address, and this is not one. Distinct from
@@ -353,12 +353,14 @@ export function anchorPageId(noteId, anchorSlug) {
353
353
  * Builds the link-resolution tables for a content tree.
354
354
  *
355
355
  * @param {Array<{type: string, id: string, shortcode?: string|null,
356
- * name?: string, pack?: string, docPack?: string,
356
+ * name?: string, pack?: string, docPack?: string, none?: boolean,
357
357
  * draft?: boolean}>} docs -
358
358
  * One entry per content note. `pack` / `docPack` name the packs the note's
359
359
  * document and its documentation entry landed in; omitted, the conventional
360
- * one-pack-per-type names stand in. `draft` says the note carries the `draft`
361
- * tag, which marks links *into* it and changes nothing else.
360
+ * one-pack-per-type names stand in. `none` says the note declares
361
+ * `pack: none` and compiles into no document, so it has no UUID to link to.
362
+ * `draft` says the note carries the `draft` tag, which marks links *into* it
363
+ * and changes nothing else.
362
364
  * @param {string} packageId - The Foundry package shipping the packs; the first
363
365
  * segment of every emitted UUID.
364
366
  * @param {Map<string, object>} [foreign] - Canonically keyed entries from
@@ -408,17 +410,29 @@ export function buildWikilinkIndex(
408
410
  if (!d.id || !d.type) continue;
409
411
  types.add(norm(d.type));
410
412
 
411
- uuidByDoc.set(d, {
412
- // `d.pack` is where this note's document actually landed, resolved
413
- // by the pack router when the index was collected. A repository may
414
- // ship several packs of one type and a UUID carries the
415
- // pack name, so the address cannot be derived from the type alone.
416
- uuid: compendiumUuid(packageId, d.type, d.id, d.pack),
417
- // An item's prose compiles into a separate JournalEntry, addressed
418
- // by the virtual `doc<type>` qualifier. Its id is derived from the
419
- // item's, so its address is knowable here too.
420
- docUuid: compendiumUuid(packageId, "doc", itemDocEntryId(d.id), d.docPack),
421
- });
413
+ uuidByDoc.set(
414
+ d,
415
+ // A note declaring `pack: none` compiles into no document, so it
416
+ // has no address in any compendium: a link to it is a page on the
417
+ // web and prose in a journal. Recorded as an entry with no UUIDs
418
+ // rather than left out, so the address still resolves and a link
419
+ // to it is never reported as dead.
420
+ d.none ?
421
+ { uuid: undefined, docUuid: undefined }
422
+ : {
423
+ // `d.pack` is where this note's document actually landed,
424
+ // resolved by the pack router when the index was
425
+ // collected. A repository may ship several packs of one
426
+ // type and a UUID carries the pack name, so the address
427
+ // cannot be derived from the type alone.
428
+ uuid: compendiumUuid(packageId, d.type, d.id, d.pack),
429
+ // An item's prose compiles into a separate JournalEntry,
430
+ // addressed by the virtual `doc<type>` qualifier. Its id
431
+ // is derived from the item's, so its address is knowable
432
+ // here too.
433
+ docUuid: compendiumUuid(packageId, "doc", itemDocEntryId(d.id), d.docPack),
434
+ },
435
+ );
422
436
 
423
437
  if (d.shortcode) byShortcode.set(`${norm(d.type)}/${norm(d.shortcode)}`, d);
424
438
  }
@@ -783,6 +797,13 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
783
797
  docUuid: compendiumUuid(index.packageId, "doc", itemDocEntryId(doc.id), doc.docPack),
784
798
  };
785
799
  const entryUuid = itemDoc ? addresses.docUuid : addresses.uuid;
800
+ // The target is a note that compiles into no document — `pack: none`.
801
+ // The address is real and the page exists on the web, so this is not
802
+ // a dead link and must not fail the build; but a compendium has
803
+ // nothing to open, so the reader gets the prose and no `@UUID`. The
804
+ // mirror of what the website does for an address that publishes a
805
+ // document and no page.
806
+ if (entryUuid === undefined) return text;
786
807
  const entryId = itemDoc ? itemDocEntryId(doc.id) : doc.id;
787
808
  const isJournal = itemDoc || packForType(doc.type).docType === "JournalEntry";
788
809
  // A JournalEntry link opens a journal — at its first page, or at the
package/manifest.mjs CHANGED
@@ -28,10 +28,12 @@
28
28
  *
29
29
  * - **Declared** — the `packageBuild.manifest` block, emitted unchanged, so a
30
30
  * key Foundry adds in a later version needs no release of this package.
31
- * - **Derived** — the identity, the description, the version, the release
32
- * addresses, the compatibility ranges and the pack list. Declaring one of
33
- * these is an error rather than an override: the authored copy would be
34
- * silently overwritten.
31
+ * `descriptionHtml` is the one exception, folded into the derived
32
+ * `description` below rather than surviving under its own name.
33
+ * - **Derived** the identity, the description (from `descriptionHtml`), the
34
+ * version, the release addresses, the compatibility ranges and the pack
35
+ * list. Declaring `description` directly is an error rather than an
36
+ * override: the authored copy would be silently overwritten.
35
37
  * - **Computed** — namespaced `flags` a repository works out for itself.
36
38
  *
37
39
  * **Nothing here invents an address.** The repository URL is read from
@@ -459,11 +461,13 @@ function withoutBuildKeys(entry) {
459
461
  * Three kinds of key end up in the result:
460
462
  *
461
463
  * - **Declared** — everything in `packageBuild.manifest`, emitted unchanged, so
462
- * a key Foundry adds later needs no release of this package.
464
+ * a key Foundry adds later needs no release of this package. The one
465
+ * exception is `descriptionHtml`, folded into the description below rather
466
+ * than surviving under its own name.
463
467
  * - **Derived** — the identity, the description, the release addresses, the
464
468
  * version, the Foundry and system compatibility ranges, and the pack list.
465
- * These are refused if also declared: an authored copy would be overwritten
466
- * and the two would disagree with nothing to say so.
469
+ * These are refused if also declared (`description` directly; `descriptionHtml`
470
+ * is how it is authored) and the two would disagree with nothing to say so.
467
471
  * - **Computed** — namespaced `flags` a repository works out for itself, merged
468
472
  * over any it declared.
469
473
  *
@@ -479,7 +483,9 @@ function withoutBuildKeys(entry) {
479
483
  * @returns {object} The manifest, ready to serialise.
480
484
  */
481
485
  export function buildManifest({ config, packageJson, artifact, flags }) {
482
- const declared = config.packageBuild?.manifest ?? {};
486
+ // `descriptionHtml` is the authored source of `description` — pulled out
487
+ // so it never survives the spread below under its own name.
488
+ const { descriptionHtml, ...declared } = config.packageBuild?.manifest ?? {};
483
489
  const repoUrl = normalizeRepoUrl(packageJson.repository);
484
490
 
485
491
  const derived = {
@@ -495,8 +501,8 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
495
501
  };
496
502
  // Own-property presence, not just value, decides whether a key survives
497
503
  // into `ordered` below — an explicit `undefined` would still occupy a slot
498
- // in it. Set only when `package.json` actually declares one.
499
- if (packageJson.description !== undefined) derived.description = packageJson.description;
504
+ // in it. Set only when the repository actually declares one.
505
+ if (descriptionHtml !== undefined) derived.description = descriptionHtml;
500
506
  if (config.compatibility) derived.compatibility = config.compatibility;
501
507
 
502
508
  // `requiresSystem` is the gate half of the declare/require split. It
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.1.1",
3
+ "version": "22.3.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
package/release.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  /**
15
15
  * The release archive — the two files a Foundry package's GitHub Release
16
- * carries.
16
+ * carries, plus the optional siblings a repository opts into.
17
17
  *
18
18
  * Foundry installs a package by fetching the `download` URL its manifest
19
19
  * advertises, so a release publishes `<artifact>.zip`, the whole staged tree,
@@ -21,7 +21,10 @@
21
21
  * re-fetches to notice a new version. A package that ships content publishes a
22
22
  * third: the content index other packages resolve its addresses through,
23
23
  * named by the `flags.metadataUrl` the manifest advertises. Every name is fixed
24
- * by what the manifest says, not chosen here — see `manifest.mjs`.
24
+ * by what the manifest says, not chosen here — see `manifest.mjs`. A package
25
+ * that publishes a DataModel schema (`packageBuild.schema`, staged through
26
+ * `packageBuild.assets`) gets a fourth: `schema.json`, published whenever the
27
+ * stage carries one and silently skipped otherwise.
25
28
  *
26
29
  * Kept apart from `stage.mjs` because this is the only part of assembling a
27
30
  * package that needs a dependency. A repository that never cuts a release from
@@ -40,6 +43,8 @@ import path from "node:path";
40
43
  // release job came to fail before a single byte was written.
41
44
  import { ZipArchive } from "archiver";
42
45
 
46
+ import { SCHEMA_ARTIFACT_FILE } from "./engine/foreign-catalog.mjs";
47
+
43
48
  /**
44
49
  * Zip the staged tree and place the manifest beside the archive.
45
50
  *
@@ -60,14 +65,14 @@ import { ZipArchive } from "archiver";
60
65
  * @param {boolean} [opts.pdf] - Whether to build the book that ships beside the
61
66
  * archive. `true` by default; `false` skips the build and reports the skip.
62
67
  * @returns {Promise<{zip: string, manifest: string, metadata?: string,
63
- * pdf?: string, pdfFindings: object[], pdfSkipped: string|null,
68
+ * schema?: string, pdf?: string, pdfFindings: object[], pdfSkipped: string|null,
64
69
  * bytes: number, version: string}>} The paths written, what the book build
65
70
  * found, the archive's size, and the version the manifest declares.
66
- * `metadata` is absent when the manifest advertises no content index, and
67
- * `pdf` is absent when no book was written. `pdfSkipped` is `null` when a
68
- * book was built, and otherwise the reason none was — itself `null` when the
69
- * book builder could not be loaded, which is reported through
70
- * `pdfFindings`.
71
+ * `metadata` is absent when the manifest advertises no content index,
72
+ * `schema` is absent when the stage carries no `schema.json`, and `pdf` is
73
+ * absent when no book was written. `pdfSkipped` is `null` when a book was
74
+ * built, and otherwise the reason none was itself `null` when the book
75
+ * builder could not be loaded, which is reported through `pdfFindings`.
71
76
  * @throws {Error} When the stage has no manifest — there is nothing to release,
72
77
  * and an archive without one installs as nothing.
73
78
  */
@@ -119,6 +124,7 @@ export async function packRelease({
119
124
  await fsp.copyFile(stagedManifest, path.join(out, manifestName));
120
125
 
121
126
  const metadata = await publishMetadataIndex({ manifest, stage, out, metadataDir });
127
+ const schema = await publishSchemaAsset({ stage, out });
122
128
 
123
129
  // Last, and never fatal: the archive and the manifest are the release, and
124
130
  // a book that failed to set is a reported problem rather than a reason to
@@ -132,6 +138,7 @@ export async function packRelease({
132
138
  zip: zipPath,
133
139
  manifest: path.join(out, manifestName),
134
140
  ...(metadata ? { metadata } : {}),
141
+ ...(schema ? { schema } : {}),
135
142
  ...(book.pdf ? { pdf: book.pdf } : {}),
136
143
  pdfFindings: book.findings,
137
144
  pdfSkipped: book.pdf ? null : book.reason,
@@ -226,3 +233,29 @@ async function publishMetadataIndex({ manifest, stage, out, metadataDir }) {
226
233
  await fsp.copyFile(found, dest);
227
234
  return dest;
228
235
  }
236
+
237
+ /**
238
+ * Place the published DataModel schema beside the archive, when the stage
239
+ * carries one.
240
+ *
241
+ * **The stage is the one place this looks.** `package-build schema` writes
242
+ * `build/schema.json`, and a repository that wants it released names it in
243
+ * `packageBuild.assets` (`from: build/schema.json`) the way every other
244
+ * staged file is declared — so a schema at the stage root is a repository
245
+ * that opted in, and its absence is a repository that has not, which is
246
+ * exactly as releasable as one that never adopted the artifact at all.
247
+ *
248
+ * @param {object} opts
249
+ * @param {string} opts.stage - The staged tree.
250
+ * @param {string} opts.out - Where release assets are written.
251
+ * @returns {Promise<string|undefined>} The published path, or nothing when
252
+ * the stage carries no `schema.json`.
253
+ */
254
+ async function publishSchemaAsset({ stage, out }) {
255
+ const src = path.join(stage, SCHEMA_ARTIFACT_FILE);
256
+ if (!fs.existsSync(src)) return undefined;
257
+
258
+ const dest = path.join(out, SCHEMA_ARTIFACT_FILE);
259
+ await fsp.copyFile(src, dest);
260
+ return dest;
261
+ }
@@ -12,25 +12,23 @@
12
12
  */
13
13
 
14
14
  /**
15
- * The `sohl` knowledgebase's own body passes.
15
+ * The `sohl` knowledgebase's own body pass.
16
16
  *
17
17
  * `content-build site` publishes a content tree as a website, and almost all of
18
- * that job is the same for every package. These two rewrites are not: they are
19
- * driven by a TypeDoc symbol map and a repository layout only this package has,
20
- * and they are ruled explicitly per-consumer.
18
+ * that job is the same for every package. This rewrite is not: it is driven by
19
+ * a TypeDoc symbol map only this package has, and it is ruled explicitly
20
+ * per-consumer.
21
21
  *
22
- * They live here rather than in a script in the consuming repository for the
22
+ * It lives here rather than in a script in the consuming repository for the
23
23
  * same reason `sohl/item-builders.mjs` and `sohl/being-info.mjs` do: a
24
24
  * configuration is data and cannot hold a function, so a package-specific table
25
25
  * of code is **named** from configuration and resolved from a registry. What is
26
26
  * package-specific is the code; what is repository-specific — where the symbol
27
- * map sits, what the API is served at, which GitHub tree to link into — is
28
- * options, supplied beside the name.
27
+ * map sits, what the API is served at is options, supplied beside the name.
29
28
  *
30
- * Neither *rewrite* ever fails a build. A `{@link}` the map does not know
31
- * degrades to a code span, and a relative link that resolves outside the
32
- * documentation tree becomes a GitHub blob URL. Both are legible to a reader; a
33
- * broken link or a failed build for a syntax example in prose would not be.
29
+ * The *rewrite* never fails a build. A `{@link}` the map does not know
30
+ * degrades to a code span, which is legible to a reader; a failed build for a
31
+ * syntax example in prose would not be.
34
32
  *
35
33
  * Building the bundle is a different matter: a `symbolMap` that is configured
36
34
  * and cannot be used fails, loudly, before a page is rendered. Degrading
@@ -168,80 +166,21 @@ export function resolveApiLinks(body, symbols, apiBase) {
168
166
  });
169
167
  }
170
168
 
171
- /**
172
- * Rewrites the relative links in a developer-doc body so they resolve on the
173
- * published site.
174
- *
175
- * Developer docs are authored to link one another and the source tree with
176
- * repository-relative paths, and neither target exists at the same path once
177
- * rendered. Each link is resolved against the doc's own location:
178
- *
179
- * - a `*.md` link landing inside the documentation tree becomes the published
180
- * route, preserving any `#anchor`; a `README` is its directory's landing, so
181
- * that segment is dropped.
182
- * - anything else — source, templates, a repository-root `*.md` — becomes its
183
- * GitHub blob URL.
184
- *
185
- * Absolute URLs, anchor-only links, `mailto:` and site-root links are untouched.
186
- *
187
- * @param {string} body - The markdown body.
188
- * @param {string} docRel - The doc's path relative to the documentation tree.
189
- * @param {object} options - `{ repoRoot, docsSrc, docsRel, route, blob }`.
190
- * @returns {string} The body with every relative link rewritten.
191
- */
192
- export function rewriteRepoLinks(body, docRel, options) {
193
- const { repoRoot, docsSrc, docsRel, route, blob } = options;
194
- const docDir = path.dirname(docRel);
195
- return body.replace(/\]\(([^)]+)\)/g, (whole, raw) => {
196
- // Peel an optional link title: [text](url "title").
197
- const sp = raw.search(/\s/);
198
- const href = sp === -1 ? raw : raw.slice(0, sp);
199
- const title = sp === -1 ? "" : raw.slice(sp);
200
- if (/^(https?:|mailto:|tel:|#|\/)/.test(href)) return whole;
201
-
202
- const hash = href.indexOf("#");
203
- const filePart = hash === -1 ? href : href.slice(0, hash);
204
- const anchor = hash === -1 ? "" : href.slice(hash);
205
- if (!filePart) return whole;
206
-
207
- const repoRel = path
208
- .relative(repoRoot, path.resolve(docsSrc, docDir, filePart))
209
- .replace(/\\/g, "/");
210
-
211
- let out;
212
- if (repoRel.startsWith(`${docsRel}/`) && repoRel.endsWith(".md")) {
213
- const rel2 = repoRel.slice(docsRel.length + 1, -3).toLowerCase();
214
- const devPath = path.basename(rel2) === "readme" ? path.posix.dirname(rel2) : rel2;
215
- out = `${route}${devPath === "." ? "" : `${devPath}/`}${anchor}`;
216
- } else {
217
- out = `${blob}${repoRel}${anchor}`;
218
- }
219
- return `](${out}${title})`;
220
- });
221
- }
222
-
223
169
  /**
224
170
  * The `sohl` knowledgebase pass bundle, built from its options.
225
171
  *
226
- * A pass bundle is two optional hooks the page renderer calls around wikilink
227
- * resolution, and the order matters:
228
- *
229
- * - `beforeLinks` runs on every page, before wikilinks resolve, because a
230
- * `{@link}` tag may sit inside prose a wikilink also touches.
231
- * - `afterLinks` runs only on pages from an **extra tree** — the documentation
232
- * tree — because repository-relative links are a property of how those pages
233
- * are authored, not of content notes.
234
- *
235
- * Both run inside code-fence protection, so neither can rewrite a fenced example.
172
+ * A pass bundle is the hook the page renderer calls before wikilink
173
+ * resolution: `beforeLinks` runs on every page, before wikilinks resolve,
174
+ * because a `{@link}` tag may sit inside prose a wikilink also touches. It
175
+ * runs inside code-fence protection, so it cannot rewrite a fenced example.
236
176
  *
237
177
  * @param {object} options - Resolved from `site.passOptions`.
238
178
  * @param {string} [options.symbolMap] - Path to the TypeDoc symbol map,
239
179
  * relative to `repoRoot`. Absent means no API links; present and unusable is
240
180
  * a build failure.
241
181
  * @param {string} [options.apiBase] - Where the API documentation is served.
242
- * @param {string} [options.blob] - GitHub blob base for repository files.
243
182
  * @param {string} options.repoRoot - The repository root, for relative paths.
244
- * @returns {{beforeLinks: Function, afterLinks: Function}} The bundle.
183
+ * @returns {{beforeLinks: Function}} The bundle.
245
184
  * @throws {Error} When a configured `symbolMap` cannot be resolved, read,
246
185
  * parsed, or is not a name → page object.
247
186
  */
@@ -250,15 +189,5 @@ export function sohlKbPass(options) {
250
189
  const apiBase = options.apiBase ?? "";
251
190
  return {
252
191
  beforeLinks: (text) => resolveApiLinks(text, symbols, apiBase),
253
- afterLinks: (text, page) =>
254
- page.tree ?
255
- rewriteRepoLinks(text, page.rel, {
256
- repoRoot: options.repoRoot,
257
- docsSrc: page.tree.from,
258
- docsRel: page.tree.rel,
259
- route: page.tree.route,
260
- blob: options.blob ?? "",
261
- })
262
- : text,
263
192
  };
264
193
  }
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Reject a configured value, naming the key it was written under.
3
+ *
4
+ * The dotted path rides on the error as `field` as well as appearing in the
5
+ * message, so {@link loadPackageBuildConfig} — the half that knows which file
6
+ * was read — can resolve it to a line and column. This half stays pure.
7
+ *
8
+ * Exported so a sibling module composing a configuration value this module
9
+ * does not itself validate — {@link module:engine/site-config}'s
10
+ * `hugoConfig`, checking `site.assets` — reports through the one helper
11
+ * rather than a second copy.
12
+ *
13
+ * @param {string} where - Dotted path of the offending key.
14
+ * @param {string} problem - What is wrong with it.
15
+ * @returns {never}
16
+ */
17
+ export function fail(where: string, problem: string): never;
1
18
  /**
2
19
  * Where a declared `assetTransform` is loaded from.
3
20
  *
@@ -133,10 +133,10 @@ export const DEFAULT_ADDRESS_SCHEME: Readonly<{
133
133
  * and the default.
134
134
  *
135
135
  * - `homepage` — the authored homepage, and **no other page**. The content tree
136
- * is not walked for pages, `site.sections` / `site.trees` / `site.landing`
137
- * emit nothing, and nothing serves a page for its addresses.
136
+ * is not walked for pages, `site.sections` / `site.landing` emit nothing,
137
+ * and nothing serves a page for its addresses.
138
138
  * - `content` — the homepage *plus* every page the content tree publishes: the
139
- * knowledgebase, the extra trees, the section landings.
139
+ * knowledgebase and the section landings.
140
140
  *
141
141
  * **Homepage-only is a first-class mode, not an accommodation.**
142
142
  * `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
@@ -200,10 +200,10 @@ export const DERIVED_HUGO_KEYS: Readonly<Record<string, string>>;
200
200
  * and the default.
201
201
  *
202
202
  * - `homepage` — the authored homepage, and **no other page**. The content tree
203
- * is not walked for pages, `site.sections` / `site.trees` / `site.landing`
204
- * emit nothing, and nothing serves a page for its addresses.
203
+ * is not walked for pages, `site.sections` / `site.landing` emit nothing,
204
+ * and nothing serves a page for its addresses.
205
205
  * - `content` — the homepage *plus* every page the content tree publishes: the
206
- * knowledgebase, the extra trees, the section landings.
206
+ * knowledgebase and the section landings.
207
207
  *
208
208
  * **Homepage-only is a first-class mode, not an accommodation.**
209
209
  * `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'