@heroiclands/package-build 4.0.0 → 6.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.
@@ -63,6 +63,13 @@ import {
63
63
  import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
64
64
  import { loadPackConfig } from "./pack-config.mjs";
65
65
  import { searchableFrontmatter } from "./note-package.mjs";
66
+ import {
67
+ HOMEPAGE_DESTINATION,
68
+ homepageFrontmatter,
69
+ homepageTitle,
70
+ isHomepage,
71
+ } from "./homepage.mjs";
72
+ import { publishesContentPages } from "../content-config.mjs";
66
73
 
67
74
  const require = createRequire(import.meta.url);
68
75
 
@@ -137,6 +144,10 @@ export function collectContentPages(contentBase, ctx) {
137
144
  // (#56).
138
145
  const pkg = ctx.contentPackage;
139
146
  if (!ctx.packages.has(pkg) || !fm.type) continue;
147
+ // A homepage is addressed by the *package*, not by its own name, so it
148
+ // never takes a section and a slug (#51). {@link collectHomepages}
149
+ // gathers it instead.
150
+ if (isHomepage(fm)) continue;
140
151
 
141
152
  for (const hit of frontmatterWikilinks(fm)) {
142
153
  fmLinkFindings.push({ file, ...hit });
@@ -249,6 +260,68 @@ export function collectTreePages(tree, ctx) {
249
260
  return { pages, fmLinkFindings };
250
261
  }
251
262
 
263
+ /**
264
+ * The package's homepage notes — the authored page at `/<contentPackage>/`.
265
+ *
266
+ * A separate walk from {@link collectContentPages} rather than a branch inside
267
+ * it, because in homepage-only mode it is the **whole** of the site build: the
268
+ * content tree is never read for pages at all, so the licensing constraint two
269
+ * packages ship under is a property of the code path rather than of a
270
+ * configuration that happens to be empty (#55).
271
+ *
272
+ * Returned as a list rather than as the one note there should be. Requiring
273
+ * exactly one is #52's, and it is a separate decision — this reports what it
274
+ * found so a count is visible either way.
275
+ *
276
+ * @param {string} contentBase - Absolute path to the content tree.
277
+ * @param {object} ctx - `{ skipDirectories }`.
278
+ * @returns {{pages: object[]}} The homepage notes, in walk order.
279
+ */
280
+ export function collectHomepages(contentBase, ctx) {
281
+ const pages = [];
282
+ for (const file of walkSiteTree(contentBase, ctx.skipDirectories)) {
283
+ const note = readNote(file);
284
+ if (!note || !isHomepage(note.fm)) continue;
285
+ pages.push({ kind: "homepage", file, fm: note.fm, body: note.body });
286
+ }
287
+ return { pages };
288
+ }
289
+
290
+ /**
291
+ * Writes each homepage at the package's own root.
292
+ *
293
+ * Its own writer, deliberately small. A homepage is authored markdown published
294
+ * verbatim — no table expansion, no section landing and no link resolution — so
295
+ * routing it through {@link renderPages} would buy it a pipeline it has no input
296
+ * for, and would make homepage-only mode depend on the index, the foreign
297
+ * manifests and the table universe that mode exists to not build.
298
+ *
299
+ * **Verbatim is the answer to #54, not a gap left by it.** A landing's links
300
+ * could not be *resolved* here without giving `homepage` mode the index its
301
+ * licensing fence exists to not build, so they are **checked** instead:
302
+ * {@link auditHomepageLinks} reads the `landing:` addresses and the body's
303
+ * markdown links, and reports a wikilink on the page rather than resolving one.
304
+ *
305
+ * @param {string} outRoot - The package's site root — the configured `site.out`,
306
+ * one level above the content mount.
307
+ * @param {readonly object[]} pages - From {@link collectHomepages}.
308
+ * @param {object} config - The resolved configuration, for the package name and
309
+ * the default title.
310
+ * @returns {number} How many pages were written.
311
+ */
312
+ export function writeHomepages(outRoot, pages, config) {
313
+ for (const page of pages) {
314
+ const data = homepageFrontmatter(page.fm, {
315
+ contentPackage: config.contentPackage,
316
+ title: homepageTitle(page.fm, config),
317
+ });
318
+ const dest = path.join(outRoot, HOMEPAGE_DESTINATION);
319
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
320
+ fs.writeFileSync(dest, matter.stringify(page.body, data));
321
+ }
322
+ return pages.length;
323
+ }
324
+
252
325
  /**
253
326
  * The integrity gates a site build runs before it writes anything.
254
327
  *
@@ -318,6 +391,30 @@ export function siteGates(pages, findings, { manifestDir }) {
318
391
  return out;
319
392
  }
320
393
 
394
+ /**
395
+ * The gate result of a build that ran none of them.
396
+ *
397
+ * Homepage-only publishes one authored page and resolves nothing, so every gate
398
+ * here is about a surface that mode does not have. The shape is returned all the
399
+ * same, because a caller reads the same fields whichever mode ran and a `null`
400
+ * would make each of them a special case.
401
+ *
402
+ * @returns {object} An all-clear gate result.
403
+ */
404
+ export function emptyGates() {
405
+ return {
406
+ frontmatterLinks: [],
407
+ slugErrors: [],
408
+ collisions: [],
409
+ staleManifests: [],
410
+ unaddressable: [],
411
+ conflicts: [],
412
+ index: null,
413
+ foreign: null,
414
+ manifests: null,
415
+ };
416
+ }
417
+
321
418
  /** Whether any gate produced a finding. */
322
419
  export function gatesFailed(gates) {
323
420
  return Boolean(
@@ -706,6 +803,10 @@ export function buildSite({ config, outRoot } = {}) {
706
803
  const resolved = config ?? loadPackConfig();
707
804
  const site = resolved.site;
708
805
  const scheme = resolved.publish.address;
806
+ // Homepage-only or homepage-plus-content (#55). The floor is the homepage,
807
+ // so this decides whether the *content* surfaces are published, never
808
+ // whether anything is.
809
+ const publishesContent = publishesContentPages(resolved);
709
810
 
710
811
  // Where the package is served, and where its content mounts inside it. The
711
812
  // two are separate facts: `base` is the package's own address on the site
@@ -722,9 +823,16 @@ export function buildSite({ config, outRoot } = {}) {
722
823
  // directory it was launched from (#1508).
723
824
  const outBase = resolveOutputRoot(resolved.rootDir, site.out);
724
825
  const out =
725
- outRoot ?
726
- path.resolve(outRoot)
727
- : path.join(outBase, scheme.prefix.replace(/\/$/, ""));
826
+ outRoot ? path.resolve(outRoot)
827
+ : publishesContent ?
828
+ path.join(outBase, scheme.prefix.replace(/\/$/, ""))
829
+ // Homepage-only has no content mount, so the package's root *is*
830
+ // the output root and `--out` redirects the whole of it.
831
+ : outBase;
832
+ // The homepage publishes at `/<contentPackage>/`, which is the package's
833
+ // own root — one level above the content mount, and the same directory in
834
+ // homepage-only mode.
835
+ const homeRoot = publishesContent ? outBase : out;
728
836
 
729
837
  const packages = new Set(
730
838
  site.packages.length ? site.packages : [resolved.contentPackage],
@@ -744,6 +852,25 @@ export function buildSite({ config, outRoot } = {}) {
744
852
  // note was deleted or renamed would otherwise linger and keep publishing.
745
853
  fs.rmSync(outBase, { recursive: true, force: true });
746
854
 
855
+ const homepages = collectHomepages(resolved.paths.content, ctx).pages;
856
+
857
+ // Homepage-only stops here, and stopping is the point: nothing below reads
858
+ // the content tree for pages, so `sohl-kethira-basic` and `harn-adventures`
859
+ // cannot publish one whatever else their `site:` block declares (#55).
860
+ if (!publishesContent) {
861
+ return {
862
+ gates: emptyGates(),
863
+ manifests: null,
864
+ tableErrors: [],
865
+ wikiErrors: [],
866
+ stats: {
867
+ homepages: writeHomepages(homeRoot, homepages, resolved),
868
+ landings: 0,
869
+ out: homeRoot,
870
+ },
871
+ };
872
+ }
873
+
747
874
  const content = collectContentPages(resolved.paths.content, ctx);
748
875
  const pages = [...content.pages];
749
876
  const fmLinkFindings = [...content.fmLinkFindings];
@@ -804,12 +931,21 @@ export function buildSite({ config, outRoot } = {}) {
804
931
  sectionTitle: site.backfillSections ? pluralTitle : null,
805
932
  });
806
933
 
934
+ // Last, and outside the mount: the package's front page is not part of the
935
+ // content tree it introduces.
936
+ const homepagesWritten = writeHomepages(homeRoot, homepages, resolved);
937
+
807
938
  return {
808
939
  gates,
809
940
  manifests: gates.manifests,
810
941
  tableErrors: rendered.tableErrors,
811
942
  wikiErrors: rendered.wikiErrors,
812
- stats: { ...rendered.byKind, landings, out },
943
+ stats: {
944
+ ...rendered.byKind,
945
+ homepages: homepagesWritten,
946
+ landings,
947
+ out,
948
+ },
813
949
  };
814
950
  }
815
951
 
package/manifest.mjs CHANGED
@@ -49,8 +49,11 @@
49
49
  */
50
50
 
51
51
  import fs from "node:fs/promises";
52
+ import fsSync from "node:fs";
52
53
  import path from "node:path";
53
54
 
55
+ import { emitDiagnostic, positionOfYamlPath } from "./engine/diagnostics.mjs";
56
+
54
57
  /**
55
58
  * The two package kinds Foundry defines, as the artifact name each one's
56
59
  * manifest and release archive are called.
@@ -197,6 +200,130 @@ export function manifestPacks(config) {
197
200
  });
198
201
  }
199
202
 
203
+ /**
204
+ * Where a `packFolders` declaration lives in the configuration file.
205
+ *
206
+ * @type {readonly string[]}
207
+ */
208
+ const PACK_FOLDERS_PATH = Object.freeze([
209
+ "packageBuild",
210
+ "manifest",
211
+ "packFolders",
212
+ ]);
213
+
214
+ /**
215
+ * Every pack name a folder tree names, with the folder that named it.
216
+ *
217
+ * Foundry nests pack folders three deep — `PackageCompendiumFolder` re-declares
218
+ * itself while `depth < 4` — so a rule reading only the top level would miss
219
+ * every nested name in both directions: a broken one it never checked, and a
220
+ * working one it would then report as ungrouped.
221
+ *
222
+ * @param {unknown} folders - A `packFolders` list, or a nested `folders` list.
223
+ * @param {Array<string|number>} at - Config key path of `folders`.
224
+ * @returns {Array<{pack: string, folder: string, keyPath: Array<string|number>}>}
225
+ * One entry per named pack, in declaration order, depth first.
226
+ */
227
+ function namedPacks(folders, at) {
228
+ if (!Array.isArray(folders)) return [];
229
+ const found = [];
230
+ folders.forEach((folder, index) => {
231
+ if (folder === null || typeof folder !== "object") return;
232
+ const name = String(folder.name ?? "");
233
+ const packs = Array.isArray(folder.packs) ? folder.packs : [];
234
+ packs.forEach((pack, position) => {
235
+ found.push({
236
+ pack: String(pack),
237
+ folder: name,
238
+ keyPath: [...at, index, "packs", position],
239
+ });
240
+ });
241
+ found.push(...namedPacks(folder.folders, [...at, index, "folders"]));
242
+ });
243
+ return found;
244
+ }
245
+
246
+ /**
247
+ * What `packFolders` and the derived `packs[]` disagree about.
248
+ *
249
+ * `packFolders` is the one **declared** manifest key that names something the
250
+ * build **derives**: every other declared key states a fact about the package
251
+ * (`title`, `socket`, `grid`) or addresses a staged file (`esmodules`,
252
+ * `styles`, `languages`), and a staged file is a different relation, checked
253
+ * against the stage rather than against configuration. So this is the one place
254
+ * a declaration can go stale against a value the build already computed — and
255
+ * until now nothing compared them (#81).
256
+ *
257
+ * `HarnMaster-3-FoundryVTT` shipped the consequence: its folder named four
258
+ * packs, three of which had not existed since the compendium was consolidated,
259
+ * and omitted `items` — 1,577 of 1,597 documents, loose in Foundry's compendium
260
+ * browser, with the build reporting nothing (HM3#420).
261
+ *
262
+ * **The two findings are not the same finding**, and giving them one severity
263
+ * gets one of them wrong:
264
+ *
265
+ * - _A folder names a pack that does not exist_ is an **error**. Foundry
266
+ * resolves the name against the package's own packs and silently skips what
267
+ * it cannot find, so the declaration does nothing at all; there is no
268
+ * arrangement in which it is intended, and the fix is unambiguous.
269
+ * - _A pack no folder names_ is a **warning**. It is legal and can be
270
+ * deliberate — a package may want one pack at the root — so failing on it
271
+ * would break working packages for a matter of taste. But a package that
272
+ * bothered to declare a folder rarely meant to leave one out, which is
273
+ * exactly how HM3's `items` went unnoticed.
274
+ * - _A package declaring no folders_ says **nothing**. Everything at the root
275
+ * is the majority arrangement, not an omission.
276
+ *
277
+ * Errors come first, in declaration order, then warnings in pack order: the
278
+ * unresolvable names are what a reader fixes, and a folder gaining a name often
279
+ * settles a warning too.
280
+ *
281
+ * @param {object} options
282
+ * @param {unknown} [options.packFolders] - The declared `packFolders`.
283
+ * @param {ReadonlyArray<{name: string}>} [options.packs] - The derived packs,
284
+ * as {@link manifestPacks} returns them.
285
+ * @returns {Array<{severity: "error"|"warning", message: string, pack: string,
286
+ * folder?: string, keyPath: Array<string|number>}>} The findings, ordered.
287
+ */
288
+ export function packFolderFindings({ packFolders, packs = [] }) {
289
+ if (!Array.isArray(packFolders) || packFolders.length === 0) return [];
290
+
291
+ const shipped = packs.map((pack) => pack?.name).filter(Boolean);
292
+ const known = new Set(shipped);
293
+ const named = namedPacks(packFolders, PACK_FOLDERS_PATH);
294
+ const grouped = new Set(named.map((entry) => entry.pack));
295
+
296
+ const findings = named
297
+ .filter((entry) => !known.has(entry.pack))
298
+ .map((entry) => ({
299
+ severity: /** @type {const} */ ("error"),
300
+ pack: entry.pack,
301
+ folder: entry.folder,
302
+ keyPath: entry.keyPath,
303
+ message:
304
+ `packFolders: folder "${entry.folder}" names pack ` +
305
+ `"${entry.pack}", which this package does not ship ` +
306
+ `(packs: ${shipped.join(", ")})`,
307
+ }));
308
+
309
+ for (const name of shipped) {
310
+ if (grouped.has(name)) continue;
311
+ findings.push({
312
+ severity: /** @type {const} */ ("warning"),
313
+ pack: name,
314
+ // No folder omitted it in particular — every one of them did — so
315
+ // the position is the declaration a reader edits, not one entry
316
+ // inside it. Each warning names its own pack, so they stay
317
+ // distinguishable despite sharing a line.
318
+ keyPath: [...PACK_FOLDERS_PATH],
319
+ message:
320
+ `packFolders: pack "${name}" is named by no folder, so it ` +
321
+ `ships outside every folder this package declares`,
322
+ });
323
+ }
324
+ return findings;
325
+ }
326
+
200
327
  /**
201
328
  * Relationship keys that direct the **build**, rather than describe the
202
329
  * package.
@@ -330,16 +457,66 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
330
457
  return ordered;
331
458
  }
332
459
 
460
+ /**
461
+ * Report what {@link packFolderFindings} found, and say whether it was fatal.
462
+ *
463
+ * The position comes from the configuration file, when one was named and can be
464
+ * read: a `packFolders` finding is about a line of YAML, and the file is the
465
+ * only place a line exists. Anything that cannot be established — an `.mjs`
466
+ * configuration, an unreadable file, a path that resolves to nothing — is
467
+ * dropped rather than guessed, so the diagnostic degrades from
468
+ * `file:line:column:` to `file:` to no locator at all.
469
+ *
470
+ * @param {ReturnType<typeof packFolderFindings>} findings - What was found.
471
+ * @param {string} [configFile] - Absolute path of the configuration file.
472
+ * @returns {number} How many of them were errors.
473
+ */
474
+ function reportPackFolders(findings, configFile) {
475
+ if (!findings.length) return 0;
476
+
477
+ let text;
478
+ if (configFile) {
479
+ try {
480
+ text = fsSync.readFileSync(configFile, "utf8");
481
+ } catch {
482
+ text = undefined;
483
+ }
484
+ }
485
+
486
+ let errors = 0;
487
+ for (const finding of findings) {
488
+ if (finding.severity === "error") errors += 1;
489
+ emitDiagnostic({
490
+ ...(configFile ? { file: configFile } : {}),
491
+ ...(text ? positionOfYamlPath(text, finding.keyPath) : {}),
492
+ severity: finding.severity,
493
+ message: finding.message,
494
+ });
495
+ }
496
+ return errors;
497
+ }
498
+
333
499
  /**
334
500
  * Write the generated manifest into the staged package.
335
501
  *
502
+ * The declared `packFolders` is checked against the derived `packs[]` first,
503
+ * and an unresolvable name **stops the write**: a manifest already known to
504
+ * describe packs the package does not ship should not reach the stage, where
505
+ * the next command would deploy it (#81). See {@link packFolderFindings} for
506
+ * the rule and why its two findings carry different severities.
507
+ *
336
508
  * @param {object} options - As {@link buildManifest}, plus where to write.
337
509
  * @param {object} options.config - The resolved content configuration.
338
510
  * @param {object} options.packageJson - The repository's `package.json`.
339
511
  * @param {string} options.artifact - `system` or `module`.
340
512
  * @param {string} options.outDir - Directory to write into.
341
513
  * @param {Record<string, object>} [options.flags] - Namespaced flags to merge.
514
+ * @param {string} [options.configFile] - Absolute path of the configuration
515
+ * file the manifest was resolved from, so a finding about it can be located.
516
+ * Omitting it costs the position, not the finding.
342
517
  * @returns {Promise<{path: string, manifest: object}>} Where it went, and what.
518
+ * @throws {Error} When a `packFolders` entry names a pack the package does not
519
+ * ship. Nothing is written in that case.
343
520
  */
344
521
  export async function writeManifest({
345
522
  config,
@@ -347,8 +524,26 @@ export async function writeManifest({
347
524
  artifact,
348
525
  outDir,
349
526
  flags,
527
+ configFile,
350
528
  }) {
351
529
  const manifest = buildManifest({ config, packageJson, artifact, flags });
530
+
531
+ const errors = reportPackFolders(
532
+ packFolderFindings({
533
+ packFolders: manifest.packFolders,
534
+ packs: manifest.packs,
535
+ }),
536
+ configFile,
537
+ );
538
+ if (errors) {
539
+ throw new Error(
540
+ `packFolders names ${errors} pack${errors === 1 ? "" : "s"} this ` +
541
+ `package does not ship (reported above). Foundry skips a name ` +
542
+ `it cannot resolve, so the folder would ship missing those ` +
543
+ `packs — correct \`packageBuild.manifest.packFolders\`.`,
544
+ );
545
+ }
546
+
352
547
  await fs.mkdir(outDir, { recursive: true });
353
548
  const outPath = path.join(outDir, `${artifact}.json`);
354
549
  // Trailing newline: the file is committed to a release archive and read by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "4.0.0",
3
+ "version": "6.0.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/sohl/actors.mjs CHANGED
@@ -186,8 +186,16 @@ function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
186
186
  const shadowed = [];
187
187
  for (const itemsSourceDir of itemsSourceDirs) {
188
188
  if (!fs.existsSync(itemsSourceDir)) {
189
+ // The generator orders the actors pass after every Item pass (#73),
190
+ // so a whole-package build cannot reach this. What can is a run
191
+ // restricted to this one pack, or a caller constructing the
192
+ // compiler itself — neither of which reordering a pack list fixes,
193
+ // so the message no longer suggests it.
189
194
  throw new Error(
190
- `Items source directory ${itemsSourceDir} does not exist — actors must be generated after items`,
195
+ `Items source directory ${itemsSourceDir} does not exist — ` +
196
+ `a being resolves its embedded items against the Item ` +
197
+ `packs' compiled output, so those packs must be compiled ` +
198
+ `before this one`,
191
199
  );
192
200
  }
193
201
  for (const name of fs.readdirSync(itemsSourceDir)) {
@@ -310,6 +318,11 @@ export class Actors extends BasePackCompiler {
310
318
  static id = "actors";
311
319
  static label = "actor";
312
320
 
321
+ // A being's embedded items are resolved against the *output* of the item
322
+ // passes, so every Item pack compiles before this one. Declared rather than
323
+ // left to the order `packs:` happens to list (#73).
324
+ static readsPackOutputOf = Object.freeze(["Item"]);
325
+
313
326
  /** @type {readonly string[]} */
314
327
  itemsSourceDirs;
315
328
  foreignSourceDirs;
@@ -231,11 +231,6 @@ const GEAR_COMMON = Object.freeze([
231
231
  value: true,
232
232
  describe: "Whether it is being carried. Possession state.",
233
233
  },
234
- {
235
- to: "isEquipped",
236
- value: false,
237
- describe: "Whether it is equipped. Possession state.",
238
- },
239
234
  ]);
240
235
 
241
236
  /* --------------------------------------------------------------------- */
@@ -27,35 +27,98 @@
27
27
  * map sits, what the API is served at, which GitHub tree to link into — is
28
28
  * options, supplied beside the name.
29
29
  *
30
- * Neither pass ever fails a build. A `{@link}` the map does not know degrades to
31
- * a code span, and a relative link that resolves outside the documentation tree
32
- * becomes a GitHub blob URL. Both are legible to a reader; a broken link or a
33
- * failed build for a syntax example in prose would not be.
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.
34
+ *
35
+ * Building the bundle is a different matter: a `symbolMap` that is configured
36
+ * and cannot be used fails, loudly, before a page is rendered (#75). Degrading
37
+ * a tag the map does not know is a judgement about *prose*; degrading every tag
38
+ * on the site because the map was never read is a build that lied.
34
39
  *
35
40
  * @module
36
41
  */
37
42
 
38
43
  import fs from "node:fs";
39
44
  import path from "node:path";
45
+ import log from "loglevel";
40
46
 
41
47
  /**
42
48
  * Reads the TypeDoc symbol map, or an empty one.
43
49
  *
44
- * Absent is not an error: the map is generated by `npm run docs`, and a
45
- * knowledgebase build that runs before it or in a checkout that has never run
46
- * it should publish prose with `{@link}` tags degraded to code spans rather
47
- * than refuse to build.
50
+ * **Not configuring a map is the empty case, and it is silent.** The map is
51
+ * generated by `npm run docs`, and a knowledgebase build in a repository that
52
+ * has no API documentation should publish prose with `{@link}` tags degraded to
53
+ * code spans rather than refuse to build.
54
+ *
55
+ * **Configuring one that cannot be used is a defect, and it fails the build.**
56
+ * A missing file, a malformed one, a permissions error and a path typo used to
57
+ * be indistinguishable from each other *and* from a correctly configured build
58
+ * with no symbols: a bare `catch` returned `{}` for all five. Nothing then
59
+ * compares an emitted page against what its source asked for, so the first
60
+ * observer of a broken map was a reader who clicked nothing, because every
61
+ * `{@link}` on the published site had quietly become a code span (#75).
62
+ *
63
+ * The path is resolved against the **repository root**, never the process cwd.
64
+ * `site.passOptions.symbolMap` is authored repo-relative, so a cwd-relative
65
+ * read misses the moment `content-build site` is driven from anywhere but the
66
+ * repository root — which is exactly how #51's end-to-end verification, running
67
+ * through `PACKAGE_BUILD_CONFIG` from outside the tree, found this.
48
68
  *
49
69
  * @param {string|undefined} file - Path to the map, if configured.
70
+ * @param {string|undefined} repoRoot - The repository root to resolve against.
50
71
  * @returns {Record<string, string>} Qualified name → API page path.
72
+ * @throws {Error} When a configured map cannot be resolved, read, parsed, or is
73
+ * not a name → page object.
51
74
  */
52
- function readSymbolMap(file) {
75
+ function readSymbolMap(file, repoRoot) {
53
76
  if (!file) return {};
77
+
78
+ // Refused rather than quietly resolved against the cwd, which is the
79
+ // defect this function was fixed for: a fallback would let it return.
80
+ if (!path.isAbsolute(file) && !repoRoot) {
81
+ throw new Error(
82
+ `site.passOptions.symbolMap ${JSON.stringify(file)} is relative ` +
83
+ `and no repoRoot was supplied to resolve it against`,
84
+ );
85
+ }
86
+ const resolved = path.resolve(repoRoot ?? "", file);
87
+
88
+ let raw;
54
89
  try {
55
- return JSON.parse(fs.readFileSync(file, "utf8"));
56
- } catch {
57
- return {};
90
+ raw = fs.readFileSync(resolved, "utf8");
91
+ } catch (err) {
92
+ throw new Error(
93
+ `site.passOptions.symbolMap ${resolved} cannot be read: ` +
94
+ `${err.message}`,
95
+ );
58
96
  }
97
+
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(raw);
101
+ } catch (err) {
102
+ throw new Error(
103
+ `site.passOptions.symbolMap ${resolved} is not valid JSON: ` +
104
+ `${err.message}`,
105
+ );
106
+ }
107
+
108
+ // `[]`, `"x"` and `null` all parse, and then every lookup misses — the same
109
+ // silent degradation reached a different way.
110
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
111
+ throw new Error(
112
+ `site.passOptions.symbolMap ${resolved} is not an object mapping ` +
113
+ `qualified names to API page paths`,
114
+ );
115
+ }
116
+
117
+ // Reported because a map that loaded and a map that loaded empty are
118
+ // otherwise indistinguishable without reading the emitted HTML — and an
119
+ // empty one degrades every tag exactly as a missing one used to.
120
+ log.info(`resolved ${Object.keys(parsed).length} API symbols from ${file}`);
121
+ return parsed;
59
122
  }
60
123
 
61
124
  /**
@@ -182,14 +245,18 @@ export function rewriteRepoLinks(body, docRel, options) {
182
245
  * Both run inside code-fence protection, so neither can rewrite a fenced example.
183
246
  *
184
247
  * @param {object} options - Resolved from `site.passOptions`.
185
- * @param {string} [options.symbolMap] - Path to the TypeDoc symbol map.
248
+ * @param {string} [options.symbolMap] - Path to the TypeDoc symbol map,
249
+ * relative to `repoRoot`. Absent means no API links; present and unusable is
250
+ * a build failure.
186
251
  * @param {string} [options.apiBase] - Where the API documentation is served.
187
252
  * @param {string} [options.blob] - GitHub blob base for repository files.
188
253
  * @param {string} options.repoRoot - The repository root, for relative paths.
189
254
  * @returns {{beforeLinks: Function, afterLinks: Function}} The bundle.
255
+ * @throws {Error} When a configured `symbolMap` cannot be resolved, read,
256
+ * parsed, or is not a name → page object.
190
257
  */
191
258
  export function sohlKbPass(options) {
192
- const symbols = readSymbolMap(options.symbolMap);
259
+ const symbols = readSymbolMap(options.symbolMap, options.repoRoot);
193
260
  const apiBase = options.apiBase ?? "";
194
261
  return {
195
262
  beforeLinks: (text) => resolveApiLinks(text, symbols, apiBase),
@@ -38,6 +38,7 @@
38
38
  */
39
39
 
40
40
  import { AS_AUTHORED, NUMBER, STRING } from "../engine/field-spec.mjs";
41
+ import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
41
42
  import { ITEM_FIELDS } from "./item-fields.mjs";
42
43
 
43
44
  /** A map-valued property, whose entries the compiler walks by key. */
@@ -302,9 +303,16 @@ const PRESENTATION_FIELDS = Object.freeze({
302
303
  * Every content type this package compiles, and what a note of that type may
303
304
  * write.
304
305
  *
306
+ * The engine's own types are merged in first, so a SoHL tree is checked against
307
+ * one vocabulary rather than two. They are declared there rather than here
308
+ * because they are note-format knowledge — a `homepage` carries no `system`
309
+ * block and would mean the same thing for a game system that is not SoHL — and
310
+ * because a package declaring no `itemBuilders` never reaches this file (#51).
311
+ *
305
312
  * @type {Readonly<Record<string, readonly import("../engine/field-spec.mjs").FieldSpec[]>>}
306
313
  */
307
314
  export const NOTE_SCHEMAS = Object.freeze({
315
+ ...ENGINE_NOTE_SCHEMAS,
308
316
  ...Object.fromEntries(
309
317
  Object.entries(ITEM_FIELDS).map(([type, fields]) => [
310
318
  type,