@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
@@ -35,7 +35,7 @@
35
35
  * ## `publish.site` is the switch, and it is the only switch
36
36
  *
37
37
  * `homepage` mode fences the content surfaces off: the tree is not walked and
38
- * `sections`, `trees` and `landing` emit nothing however they are declared. A
38
+ * `sections` and `landing` emit nothing however they are declared. A
39
39
  * PDF of the content tree is a content surface by any reading — arguably the
40
40
  * most portable one there is — so it is fenced on exactly the same terms, by
41
41
  * asking the same {@link module:content-config.publishesContentPages} the
@@ -481,7 +481,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
481
481
  * @returns {string} Typst markup.
482
482
  */
483
483
  const renderPage = (page, headingOffset, anchorPrefix) => {
484
- const src = page.relPath ?? page.rel ?? page.base;
484
+ const src = page.relPath ?? page.base;
485
485
  const wikiErrors = [];
486
486
  const { markdown, errors } = expandContentTables(page.body, {
487
487
  docs: universe.get(page.pkg) ?? [],
@@ -326,9 +326,8 @@ export function assertNoSectionField(fm, { file, absPath } = {}) {
326
326
  if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "section")) return;
327
327
 
328
328
  const err = new Error(`${sectionRetiredMessage(file)}.`);
329
- // Anchored at column 1: `site.trees[].section` is a *configuration* key of
330
- // the same name, and a nested `section:` inside some other block is not
331
- // this field — a finding about the top-level one must not open on it.
329
+ // Anchored at column 1: a nested `section:` inside some other block is
330
+ // not this field a finding about the top-level one must not open on it.
332
331
  const position = locateFrontmatterKey(absPath, "section", undefined, { topLevel: true });
333
332
  if (position) err.position = position;
334
333
  throw err;
@@ -466,8 +466,8 @@ export function compareEmittedSystem({
466
466
  * Two places to find it, because a system checks itself against source it owns
467
467
  * while a module checks against a dependency it fetched:
468
468
  *
469
- * - **A system**: its own `schema.json`, generated from its `src/` and
470
- * committed beside it.
469
+ * - **A system**: its own `schema.json`, generated from its `src/` into
470
+ * `build/` by `package-build schema`.
471
471
  * - **A module**: the copy cached by `content-build deps fetch`, from the
472
472
  * archive of the version it pins — which is what makes the comparison happen
473
473
  * at `verified` rather than against whatever the system's `main` holds today.
@@ -505,7 +505,7 @@ export function resolveSchemaArtifact(config, system = undefined) {
505
505
 
506
506
  // The system checking itself, against the schema its own build published.
507
507
  if (config.packageKind === "systems" && config.foundryPackage === systemId) {
508
- const own = path.join(config.rootDir, SCHEMA_ARTIFACT_FILE);
508
+ const own = path.join(config.rootDir, "build", SCHEMA_ARTIFACT_FILE);
509
509
  return fs.existsSync(own) ? read(own) : null;
510
510
  }
511
511
 
@@ -683,9 +683,9 @@ export function checkAuthoredSystemData(
683
683
  * subtype declares.
684
684
  *
685
685
  * The build-time face of {@link compareEmittedSystem}: it resolves the schema
686
- * the way every other check here does — the system's own committed artifact, or
687
- * the cached one from the release a module pins — and attaches the message a
688
- * reader sees.
686
+ * the way every other check here does — the system's own published artifact,
687
+ * or the cached one from the release a module pins — and attaches the message
688
+ * a reader sees.
689
689
  *
690
690
  * **Silent where there is nothing to check against**, exactly as its two
691
691
  * siblings are: a module pinning a system version released before the artifact
@@ -33,13 +33,12 @@
33
33
  * a function.
34
34
  *
35
35
  * **Every gate reports; none exits.** The integrity checks a site build needs —
36
- * a wikilink authored in frontmatter, a name that yields no slug, two pages
37
- * claiming one URL, an unusable or unaddressable foreign manifest, an address
38
- * two packages both claim, a table directive that cannot be honoured, a dead
39
- * wikilink were inline `process.exit` calls in both scripts, with no test
40
- * between them. Here each returns its findings and the command decides. That is
41
- * the rule `engine/site-index.mjs` already follows, and it is the only reason
42
- * these cases can be tested at all.
36
+ * a wikilink authored in frontmatter, a name that yields no slug, an unusable
37
+ * or unaddressable foreign manifest, a table directive that cannot be
38
+ * honoured, a dead wikilink were inline `process.exit` calls in both
39
+ * scripts, with no test between them. Here each returns its findings and the
40
+ * command decides. That is the rule `engine/site-index.mjs` already follows,
41
+ * and it is the only reason these cases can be tested at all.
43
42
  *
44
43
  * @module
45
44
  */
@@ -49,7 +48,6 @@ import path from "node:path";
49
48
  import { createRequire } from "node:module";
50
49
  import matter from "gray-matter";
51
50
 
52
- import { slugify } from "./content-slug.mjs";
53
51
  import { addressSlug } from "./content-address.mjs";
54
52
  import { protectCode } from "./code-fences.mjs";
55
53
  import { expandContentTables } from "./content-tables.mjs";
@@ -81,37 +79,6 @@ import { HUGO_CONTENT } from "./site-config.mjs";
81
79
 
82
80
  const require = createRequire(import.meta.url);
83
81
 
84
- /**
85
- * Every `.md` file under `dir`, depth-first in directory order.
86
- *
87
- * Deliberately *not* {@link walkMarkdownTree}, whose stack-based walk yields a
88
- * tree in reverse. Order was load-bearing here when the address index carried
89
- * first-writer-wins fallbacks for a page's name, filename and slug — reversing
90
- * the walk silently changed which page an ambiguous name resolved to. Those
91
- * fallbacks are gone with the bare `[[Name]]` form, so this is now
92
- * ordinary reading order rather than a dependency; it is kept because a site's
93
- * emitted pages should not reorder for no reason.
94
- *
95
- * @param {string} dir - Directory to walk.
96
- * @param {readonly string[]} skip - Directory names to ignore at any depth.
97
- * @returns {string[]} Absolute paths.
98
- */
99
- export function walkSiteTree(dir, skip = []) {
100
- const out = [];
101
- if (!fs.existsSync(dir)) return out;
102
- const skipped = new Set(skip);
103
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
104
- const full = path.join(dir, e.name);
105
- if (e.isDirectory()) {
106
- if (skipped.has(e.name)) continue;
107
- out.push(...walkSiteTree(full, skip));
108
- } else if (e.isFile() && e.name.endsWith(".md")) {
109
- out.push(full);
110
- }
111
- }
112
- return out;
113
- }
114
-
115
82
  /**
116
83
  * The content-tree files this build publishes from, in the order it emits them.
117
84
  *
@@ -135,10 +102,6 @@ export function walkSiteTree(dir, skip = []) {
135
102
  * is a fact about the filesystem, not about the content, so it can differ
136
103
  * between two checkouts of one tree; this order cannot.
137
104
  *
138
- * `collectTreePages` is deliberately **not** converted: it walks an auxiliary
139
- * tree (`site.trees`, the developer docs), which is not the content tree and
140
- * appears in no record.
141
- *
142
105
  * @param {string} contentBase - Root of the content tree.
143
106
  * @param {object} ctx - The build context. `ctx.records` is the corpus when the
144
107
  * caller already derived it — the site build derives one and hands it to both
@@ -267,73 +230,6 @@ export function collectContentPages(contentBase, ctx) {
267
230
  return { pages, addressFindings, fmLinkFindings };
268
231
  }
269
232
 
270
- /**
271
- * An extra tree's pages — a documentation tree published alongside the content.
272
- *
273
- * These preserve their **source layout** below the section rather than being
274
- * addressed by type and slug: they are a book with chapters, and a reader
275
- * follows their paths. A `README` is its directory's landing.
276
- *
277
- * **The section is the tree's, never the note's.** `tree.section` is the
278
- * mount point a `trees` entry configures — fixed, physical, and the same
279
- * value `site-index.mjs` indexes a tree page's address under. A note's own
280
- * `subType` is a genre and reaches no address, the same contract
281
- * `packageAddress()` holds for a content page: reading it here would move a
282
- * page's URL, its file destination (`pageDestination`) and the address a
283
- * wikilink cites it by, every time an author classified it.
284
- *
285
- * @param {object} tree - `{ from, rel, section, route }`.
286
- * @param {object} ctx - `{ mount }`.
287
- * @returns {{pages: object[], fmLinkFindings: object[]}}
288
- */
289
- export function collectTreePages(tree, ctx) {
290
- const pages = [];
291
- const fmLinkFindings = [];
292
-
293
- for (const file of walkSiteTree(tree.from)) {
294
- const note = readNote(file);
295
- if (!note) continue;
296
- const { fm, body } = note;
297
-
298
- for (const hit of frontmatterWikilinks(fm)) {
299
- fmLinkFindings.push({ file, ...hit });
300
- }
301
-
302
- const rel = path.relative(tree.from, file).replace(/\\/g, "/");
303
- const base = path.basename(rel);
304
- const isReadme = base.toLowerCase() === "readme.md";
305
- const sec = tree.section;
306
- const h1 = /^#\s+(.+?)\s*$/m.exec(body);
307
- const h1Title = h1 ? h1[1].replace(/\{@link\s+[^}]*\}/g, "").trim() : null;
308
- const name = fm.name?.full ?? fm.title ?? h1Title ?? path.basename(base, ".md");
309
- const slug = fm.slug ?? slugify(path.basename(base, ".md"));
310
- const relNoExt = rel.slice(0, -3).toLowerCase();
311
- const dir = path.posix.dirname(relNoExt);
312
- pages.push({
313
- kind: "tree",
314
- tree,
315
- fm,
316
- // As above: the source file, for a located link diagnostic.
317
- file,
318
- // The H1 is stripped: the page title renders it.
319
- body: body.replace(/^\s*#\s+.*$\r?\n?/m, ""),
320
- name,
321
- slug,
322
- base,
323
- rel,
324
- sec,
325
- url:
326
- isReadme ?
327
- dir === "." ?
328
- `${ctx.mount}${sec}/`
329
- : `${ctx.mount}${sec}/${dir}/`
330
- : `${ctx.mount}${sec}/${relNoExt}/`,
331
- isReadme,
332
- });
333
- }
334
- return { pages, fmLinkFindings };
335
- }
336
-
337
233
  /**
338
234
  * The package's homepage notes — the authored page at `/<contentPackage>/`.
339
235
  *
@@ -454,7 +350,6 @@ export function siteGates(pages, findings, { config }) {
454
350
  addressErrors: findings.addressFindings ?? [],
455
351
  staleManifests: [],
456
352
  unaddressable: [],
457
- conflicts: [],
458
353
  index: null,
459
354
  foreign: null,
460
355
  manifests: null,
@@ -477,14 +372,10 @@ export function siteGates(pages, findings, { config }) {
477
372
  out.unaddressable = unaddressableForeignPackages(foreign.index);
478
373
  if (out.unaddressable.length) return out;
479
374
 
480
- const index = buildSiteIndex(pages, {
375
+ out.index = buildSiteIndex(pages, {
481
376
  foreignIndex: foreign.index,
482
377
  noIndexPackages: noContentIndexPackages(config),
483
378
  });
484
- out.conflicts = index.conflicts;
485
- if (out.conflicts.length) return out;
486
-
487
- out.index = index;
488
379
  return out;
489
380
  }
490
381
 
@@ -505,7 +396,6 @@ export function emptyGates() {
505
396
  addressErrors: [],
506
397
  staleManifests: [],
507
398
  unaddressable: [],
508
- conflicts: [],
509
399
  index: null,
510
400
  foreign: null,
511
401
  manifests: null,
@@ -519,8 +409,7 @@ export function gatesFailed(gates) {
519
409
  gates.frontmatterLinks.length ||
520
410
  gates.addressErrors.length ||
521
411
  gates.staleManifests.length ||
522
- gates.unaddressable.length ||
523
- gates.conflicts.length,
412
+ gates.unaddressable.length,
524
413
  );
525
414
  }
526
415
 
@@ -622,15 +511,8 @@ export function sectionFrontmatter(meta) {
622
511
  * self-describing and makes sweeping the field out of a content tree
623
512
  * output-preserving for a site as it already is for the packs.
624
513
  *
625
- * A **tree** page is the one that still reads `readmeSections`: a `trees` entry
626
- * keeps its source layout below a named section, so its own `README` is that
627
- * section's landing and takes the title and hero the section declares.
628
- *
629
514
  * @param {object} page - The page.
630
515
  * @param {object} options
631
- * @param {Record<string, object>} [options.readmeSections] - The sections a
632
- * published tree declares, which a tree page's own `README` is the landing
633
- * for.
634
516
  * @param {(data: object, page: object) => void} [options.decorate] - Called
635
517
  * with each page's frontmatter, for whatever a consumer's own pass adds.
636
518
  * @param {(value: unknown, type: string) => string|null} [options.artSrc] -
@@ -642,37 +524,25 @@ export function sectionFrontmatter(meta) {
642
524
  * same way.
643
525
  * @returns {object} The frontmatter to write.
644
526
  */
645
- export function pageFrontmatter(page, { readmeSections = {}, decorate, webSrc, artSrc }) {
646
- const { fm, name, slug, sec, isReadme } = page;
647
- let data;
648
- if (page.kind === "content") {
649
- data = {
650
- ...fm,
651
- // Spread after the note's own frontmatter. Guarded because
652
- // `package: undefined` is not a value YAML can carry.
653
- ...(page.pkg ? { package: page.pkg } : {}),
654
- // The address, stated site-root relative, because Hugo prefixes
655
- // the site's own base to it. `slug` is written beside it
656
- // because it is the last segment of that address and Hugo's own key
657
- // for one; it decides nothing while `url` is present, but a page
658
- // that carried only `url` would report a slug Hugo had inferred
659
- // from the filename.
660
- slug,
661
- url: `/${slug}/`,
662
- title: fm.title ?? name,
663
- kbfolder: page.folder,
664
- };
665
- if (decorate) decorate(data, page);
666
- } else {
667
- // A tree's own landing describes the *mount*, and nothing beneath it. A
668
- // nested README is a sub-section's landing, and reading the section's
669
- // entry for it would title every one of them alike and hang the section
670
- // hero on each. Its title comes from its H1, like any other page's.
671
- const isSectionRoot = path.posix.dirname(page.rel) === ".";
672
- const meta = isReadme && isSectionRoot ? readmeSections[sec] : null;
673
- data = { ...fm, title: meta?.title ?? fm.title ?? name };
674
- if (meta) Object.assign(data, sectionFrontmatter(meta));
675
- }
527
+ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
528
+ const { fm, name, slug } = page;
529
+ const data = {
530
+ ...fm,
531
+ // Spread after the note's own frontmatter. Guarded because
532
+ // `package: undefined` is not a value YAML can carry.
533
+ ...(page.pkg ? { package: page.pkg } : {}),
534
+ // The address, stated site-root relative, because Hugo prefixes
535
+ // the site's own base to it. `slug` is written beside it
536
+ // because it is the last segment of that address and Hugo's own key
537
+ // for one; it decides nothing while `url` is present, but a page
538
+ // that carried only `url` would report a slug Hugo had inferred
539
+ // from the filename.
540
+ slug,
541
+ url: `/${slug}/`,
542
+ title: fm.title ?? name,
543
+ kbfolder: page.folder,
544
+ };
545
+ if (decorate) decorate(data, page);
676
546
  delete data.aliases;
677
547
  if (webSrc && artSrc) resolveArtFields(data, webSrc, artSrc);
678
548
  return data;
@@ -733,16 +603,11 @@ function isPlainObject(value) {
733
603
  * the same as another note's `type`, and `doc-gear.md` and `weapongear-gear.md`
734
604
  * are distinct whatever the sections.
735
605
  *
736
- * **A `trees` entry is the exception, and always was.** Those pages preserve
737
- * their source layout below a named section — they are a book with chapters,
738
- * addressed by their path — so a `README` there is still its directory's
739
- * `_index.md`.
606
+ * @param {object} page - The page.
607
+ * @returns {string} The file, relative to the mount.
740
608
  */
741
609
  export function pageDestination(page) {
742
- if (page.kind === "content") return `${page.slug}.md`;
743
- const rel =
744
- page.isReadme ? path.posix.join(path.posix.dirname(page.rel), "_index.md") : page.rel;
745
- return path.join(page.sec, rel);
610
+ return `${page.slug}.md`;
746
611
  }
747
612
 
748
613
  /**
@@ -755,10 +620,10 @@ export function pageDestination(page) {
755
620
  * authored as a fenced `dataview` block, which `protectCode` would otherwise
756
621
  * stash away before the expander saw it. Expanding first leaves an ordinary
757
622
  * markdown table to walk, with every other fence still protected.
758
- * 2. **Then, inside protection**: the consumer's `beforeLinks` pass, wikilink
759
- * resolution, and the consumer's `afterLinks` pass. A `{@link}` tag may sit
760
- * in prose a wikilink also touches, so the repository's own rewrites bracket
761
- * the shared one rather than replacing it.
623
+ * 2. **Then, inside protection**: the consumer's `beforeLinks` pass, then
624
+ * wikilink resolution. A `{@link}` tag may sit in prose a wikilink also
625
+ * touches, so the repository's own rewrite runs before the shared one
626
+ * rather than replacing it.
762
627
  *
763
628
  * @param {object[]} pages - Every page.
764
629
  * @param {object} options - Everything the render needs.
@@ -772,7 +637,6 @@ export function renderPages(pages, options) {
772
637
  foreign,
773
638
  universe,
774
639
  pass = {},
775
- readmeSections,
776
640
  decorate,
777
641
  linkable = (d) => Boolean(d.fm.shortcode),
778
642
  sqlTables,
@@ -834,11 +698,10 @@ export function renderPages(pages, options) {
834
698
  };
835
699
 
836
700
  for (const page of pages) {
837
- // The page's path in the tree an author edits: below the content root
838
- // for a content note, below the tree's own root for a `trees` page. It
839
- // is not composed as `<section>/<basename>` for a content note,
840
- // which named a directory that was never the note's.
841
- const src = page.relPath ?? page.rel ?? page.base;
701
+ // The page's path in the tree an author edits, below the content
702
+ // root. It is not composed as `<section>/<basename>`, which named a
703
+ // directory that was never the note's.
704
+ const src = page.relPath ?? page.base;
842
705
  const ctx = wikiContext(index, {
843
706
  src,
844
707
  file: page.file,
@@ -854,7 +717,6 @@ export function renderPages(pages, options) {
854
717
  let t = text;
855
718
  if (pass.beforeLinks) t = pass.beforeLinks(t, page);
856
719
  t = resolveWebWikilinks(t, ctx);
857
- if (pass.afterLinks) t = pass.afterLinks(t, page);
858
720
  // Last, so a consumer's own rewrites see the image as the note
859
721
  // wrote it rather than as a figure. Hugo is handed markdown, not a
860
722
  // rendered page, so a `{…}` directive left in the body would reach
@@ -862,26 +724,22 @@ export function renderPages(pages, options) {
862
724
  return renderImageFigures(t, webSrc);
863
725
  };
864
726
 
865
- let body = page.body;
866
- if (page.kind === "content") {
867
- const { markdown, errors } = expandContentTables(body, {
868
- docs: universe.get(page.pkg) ?? [],
869
- linkable,
870
- source: src,
871
- // Prepared before this render began DuckDB is async and this
872
- // is not. Keyed by the note's own file, absolute here as in
873
- // every other pass, so the three cannot disagree about a note.
874
- sqlTables: sqlTables?.get(page.file),
875
- self: {
876
- fm: searchableFrontmatter(page.fm, page.pkg),
877
- path: page.relPath,
878
- },
879
- });
880
- tableErrors.push(...errors);
881
- body = markdown;
882
- }
727
+ const { markdown: body, errors } = expandContentTables(page.body, {
728
+ docs: universe.get(page.pkg) ?? [],
729
+ linkable,
730
+ source: src,
731
+ // Prepared before this render began — DuckDB is async and this
732
+ // is not. Keyed by the note's own file, absolute here as in
733
+ // every other pass, so the three cannot disagree about a note.
734
+ sqlTables: sqlTables?.get(page.file),
735
+ self: {
736
+ fm: searchableFrontmatter(page.fm, page.pkg),
737
+ path: page.relPath,
738
+ },
739
+ });
740
+ tableErrors.push(...errors);
883
741
 
884
- const data = pageFrontmatter(page, { readmeSections, decorate, webSrc, artSrc });
742
+ const data = pageFrontmatter(page, { decorate, webSrc, artSrc });
885
743
  const dest = path.join(outRoot, pageDestination(page));
886
744
  fs.mkdirSync(path.dirname(dest), { recursive: true });
887
745
  fs.writeFileSync(dest, matter.stringify(protectCode(body, resolve), data));
@@ -915,9 +773,9 @@ export function renderPages(pages, options) {
915
773
  * or its own address publishes nothing. Hugo generates a section page
916
774
  * automatically only for a *top-level* content directory; below that, a
917
775
  * directory without an `_index.md` is not a section, so its URL 404s while its
918
- * children publish normally. With content pages flat, what that reaches is a
919
- * `trees` entry's directory the one thing left below the mount that a note
920
- * creates.
776
+ * children publish normally. With content pages flat, no note creates a
777
+ * directory below the mount, so this reaches only what something else
778
+ * placed there.
921
779
  *
922
780
  * **A section listing is not a page listing any more.** A layout that reads
923
781
  * `.Pages` off a section it declares here will find nothing, because no file is
@@ -1016,7 +874,7 @@ const SITE_PASSES = Object.freeze({
1016
874
  *
1017
875
  * @param {string|undefined} name - The configured name.
1018
876
  * @param {object} options - The configured options, plus `repoRoot`.
1019
- * @returns {{beforeLinks?: Function, afterLinks?: Function}} The bundle.
877
+ * @returns {{beforeLinks?: Function}} The bundle.
1020
878
  */
1021
879
  export function resolveSitePass(name, options) {
1022
880
  if (!name) return {};
@@ -1177,8 +1035,7 @@ export function buildSite({ config, sqlTables } = {}) {
1177
1035
  }
1178
1036
 
1179
1037
  const content = collectContentPages(resolved.paths.content, ctx);
1180
- const pages = [...content.pages];
1181
- const fmLinkFindings = [...content.fmLinkFindings];
1038
+ const { pages } = content;
1182
1039
 
1183
1040
  // The homepage is **indexed but not rendered**. Now that it has an
1184
1041
  // address, `[[homepage-root|Text]]` is an ordinary wikilink and has to
@@ -1197,22 +1054,7 @@ export function buildSite({ config, sqlTables } = {}) {
1197
1054
  url: `${base}${addressSlug(page.fm)}/`,
1198
1055
  }));
1199
1056
 
1200
- const trees = site.trees.map((t) => ({
1201
- ...t,
1202
- from: path.resolve(resolved.rootDir, t.from),
1203
- route: `${mount}${t.section}/`,
1204
- }));
1205
- for (const tree of trees) {
1206
- const got = collectTreePages(tree, ctx);
1207
- pages.push(...got.pages);
1208
- fmLinkFindings.push(...got.fmLinkFindings);
1209
- }
1210
-
1211
- const gates = siteGates(
1212
- [...pages, ...homepageEntries],
1213
- { ...content, fmLinkFindings },
1214
- { config: resolved },
1215
- );
1057
+ const gates = siteGates([...pages, ...homepageEntries], content, { config: resolved });
1216
1058
  if (gatesFailed(gates)) {
1217
1059
  return {
1218
1060
  gates,
@@ -1238,7 +1080,6 @@ export function buildSite({ config, sqlTables } = {}) {
1238
1080
  foreign: gates.foreign,
1239
1081
  universe: tableUniverse(pages),
1240
1082
  pass,
1241
- readmeSections: site.readmeSections,
1242
1083
  // What counts as a being is the toolchain's to say, not a consumer's.
1243
1084
  // Asking in a consumer's script is how one came to still be checking
1244
1085
  // `character` and `creature` months after they were retired, and to
@@ -44,7 +44,7 @@ import fs from "node:fs";
44
44
  import path from "node:path";
45
45
  import { stringify as stringifyToml } from "smol-toml";
46
46
 
47
- import { checkHomepage } from "../config.mjs";
47
+ import { checkHomepage, fail } from "../config.mjs";
48
48
  import { slugify } from "./content-slug.mjs";
49
49
 
50
50
  /** The Hugo source directory, relative to the repository root. */
@@ -381,7 +381,6 @@ function deepMerge(base, overrides) {
381
381
  *
382
382
  * @param {object} options - The sources.
383
383
  * @param {object} options.config - The resolved build configuration.
384
- * @param {string} [options.description] - `package.json`'s `description`.
385
384
  * @param {readonly NavigationEntry[]} options.navigation - The navigation.
386
385
  * @param {string} options.themesDir - From {@link resolveThemesDir}.
387
386
  * @param {boolean} [options.hasTags] - Whether any note the site build walked
@@ -389,9 +388,10 @@ function deepMerge(base, overrides) {
389
388
  * `hasTags`. Defaults to `false` — no tagged note, no taxonomy pages.
390
389
  * @returns {Record<string, any>} The configuration Hugo reads.
391
390
  * @throws {TypeError} When `homepage` fails `checkHomepage`, or the
392
- * configuration declares no `packageBuild.manifest.title`.
391
+ * configuration declares no `packageBuild.manifest.title`, no
392
+ * `site.description`, or no `site.assets`.
393
393
  */
394
- export function hugoConfig({ config, description, navigation, themesDir, hasTags = false }) {
394
+ export function hugoConfig({ config, navigation, themesDir, hasTags = false }) {
395
395
  checkHomepage(config.homepage, config.contentPackage);
396
396
 
397
397
  const title = config.packageBuild?.manifest?.title;
@@ -401,10 +401,23 @@ export function hugoConfig({ config, description, navigation, themesDir, hasTags
401
401
  "and the site's `title` reads from it.",
402
402
  );
403
403
  }
404
+ if (!config.site.description) {
405
+ fail(
406
+ "site.description",
407
+ 'is not declared, and the site\'s `<meta name="description">` reads from it',
408
+ );
409
+ }
410
+ if (!config.site.assets) {
411
+ fail(
412
+ "site.assets",
413
+ "is not declared, and a site build needs one — it is the host every " +
414
+ "package's imagery is served from, and the theme resolves every " +
415
+ "relative asset against it",
416
+ );
417
+ }
404
418
 
405
419
  /** @type {Record<string, unknown>} */
406
- const params = {};
407
- if (typeof description === "string" && description.trim()) params.description = description;
420
+ const params = { description: config.site.description };
408
421
  if (config.author?.name) params.author = config.author.name;
409
422
  if (config.site.assets) params.cdnBaseURL = config.site.assets;
410
423
  params.brand = { ...BRAND };
@@ -449,24 +462,12 @@ export function hugoToml(generated) {
449
462
  );
450
463
  }
451
464
 
452
- /**
453
- * `package.json`'s `description`, or `undefined` when it declares none.
454
- *
455
- * @param {string} rootDir - The repository root.
456
- * @returns {string|undefined} The description.
457
- */
458
- function packageDescription(rootDir) {
459
- const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
460
- return typeof pkg.description === "string" ? pkg.description : undefined;
461
- }
462
-
463
465
  /**
464
466
  * The Hugo configuration, every source read from the repository.
465
467
  *
466
- * Reads `package.json`, the cached navigation and the installed theme's
467
- * location, and composes them with {@link hugoConfig}. Nothing is written, so
468
- * a caller can run this before touching the output tree and fail with it
469
- * intact.
468
+ * Reads the cached navigation and the installed theme's location, and
469
+ * composes them with {@link hugoConfig}. Nothing is written, so a caller can
470
+ * run this before touching the output tree and fail with it intact.
470
471
  *
471
472
  * @param {object} config - The resolved build configuration.
472
473
  * @param {object} [options] - Options.
@@ -480,7 +481,6 @@ function packageDescription(rootDir) {
480
481
  export function generateHugoConfig(config, { hasTags = false } = {}) {
481
482
  return hugoConfig({
482
483
  config,
483
- description: packageDescription(config.rootDir),
484
484
  navigation: readCachedNavigation(config),
485
485
  themesDir: resolveThemesDir(config.rootDir),
486
486
  hasTags,