@heroiclands/package-build 22.0.3 → 22.1.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.
@@ -175,6 +175,10 @@ export const DEFAULT_PATHS = /** @type {const} */ ({
175
175
  // dependency — so nesting one under the other would imply a containment
176
176
  // that does not hold.
177
177
  metadataCache: "build/cache/metadata",
178
+ // Where the site navigation heroiclands.org publishes is fetched to.
179
+ // Beside the other two: it is fetched by the same command and read under
180
+ // the same complete-marker rule.
181
+ navigationCache: "build/cache/navigation",
178
182
  });
179
183
 
180
184
  /**
@@ -387,6 +391,8 @@ export function publishesContentPages(config) {
387
391
  * index is fetched to. Inbound,
388
392
  * for *every* declared dependency, not
389
393
  * only those supplying a catalogue.
394
+ * @property {string} [navigationCache] Where the site navigation is fetched
395
+ * to, for the generated Hugo menu.
390
396
  */
391
397
 
392
398
  /**
@@ -401,6 +407,7 @@ export function publishesContentPages(config) {
401
407
  * @property {string} unpack
402
408
  * @property {string} foreignCache
403
409
  * @property {string} metadataCache
410
+ * @property {string} navigationCache
404
411
  */
405
412
 
406
413
  /**
@@ -578,6 +585,13 @@ export function publishesContentPages(config) {
578
585
  * Refused by a `documentation`
579
586
  * package, which ships no Foundry
580
587
  * package.
588
+ * @property {string} [homepage] `package.json`'s own `homepage` —
589
+ * the site build's `baseURL`. Checked
590
+ * by `checkHomepage` in
591
+ * `config.mjs`.
592
+ * @property {string|{name: string, email?: string, url?: string}} [author]
593
+ * `package.json`'s own `author`, in
594
+ * either of npm's forms.
581
595
  * @property {PackageKind} packageKind Whether the package is a system, a
582
596
  * module, or documentation — the kind
583
597
  * that publishes a site and a book
@@ -637,6 +651,13 @@ export function publishesContentPages(config) {
637
651
  * @property {string} contentPackage
638
652
  * @property {string|null} foundryPackage `null` for a `documentation`
639
653
  * package, which ships no Foundry package.
654
+ * @property {string|null} homepage `package.json`'s own `homepage`,
655
+ * checked by `checkHomepage` in
656
+ * `config.mjs`.
657
+ * @property {Readonly<{name: string, email?: string, url?: string}>|null} author
658
+ * `package.json`'s own `author`, normalised
659
+ * from either of npm's forms; `null` when
660
+ * the package declares none.
640
661
  * @property {PackageKind} packageKind
641
662
  * @property {string|null} assetRoot Derived, and **conditional**: the served
642
663
  * Foundry asset root,
@@ -705,6 +726,8 @@ const CONFIG_KEYS = [
705
726
  "rootDir",
706
727
  "contentPackage",
707
728
  "foundryPackage",
729
+ "homepage",
730
+ "author",
708
731
  "packageKind",
709
732
  "stats",
710
733
  "itemBuilders",
@@ -726,7 +749,6 @@ const SYSTEM_KEYS = ["manifest", "compatibility"];
726
749
  const COMPATIBILITY_KEYS = ["minimum", "verified"];
727
750
  const DOCS_KEYS = ["itemFields"];
728
751
  const SITE_KEYS = [
729
- "out",
730
752
  "base",
731
753
  "assets",
732
754
  "packages",
@@ -737,8 +759,14 @@ const SITE_KEYS = [
737
759
  "pass",
738
760
  "passOptions",
739
761
  "backfillSections",
762
+ "list",
763
+ "notfound",
764
+ "hugo",
740
765
  ];
741
766
  const SITE_TREE_KEYS = ["from", "section"];
767
+ const SITE_LIST_KEYS = ["shortcodes"];
768
+ const SITE_NOTFOUND_KEYS = ["tagline", "sitenoun", "heroimage", "links"];
769
+ const SITE_NOTFOUND_LINK_KEYS = ["title", "url", "text"];
742
770
  const PDF_KEYS = ["title", "subtitle", "document", "out", "front", "fonts", "iconFonts", "binary"];
743
771
  const PDF_FONT_KEYS = ["serif", "sans", "mono", "path"];
744
772
  const EMPTY_PDF_FONTS = Object.freeze({ serif: "", sans: "", mono: "", path: "" });
@@ -753,6 +781,7 @@ const RELATIONSHIP_KEYS = [
753
781
  "compatibility",
754
782
  "itemCatalog",
755
783
  ];
784
+ const AUTHOR_KEYS = ["name", "email", "url"];
756
785
  const ITEM_BUILDER_KEYS = ["system", "img", "fields"];
757
786
  const ITEM_REGISTRY_KEYS = ["system", "builders"];
758
787
  const PACK_KEYS = [
@@ -964,6 +993,56 @@ function optionalString(value, field) {
964
993
  return value;
965
994
  }
966
995
 
996
+ /**
997
+ * npm's `author` field, in either of its two forms.
998
+ *
999
+ * `package.json` accepts a single string — `"Name <email> (url)"`, with the
1000
+ * email and the URL both optional — or an object carrying the same three
1001
+ * parts. Both normalise to one shape, so the site build reads one field
1002
+ * instead of branching on which form a repository happened to write.
1003
+ *
1004
+ * @type {RegExp}
1005
+ */
1006
+ const AUTHOR_STRING = /^([^<(]*?)\s*(?:<([^>]*)>)?\s*(?:\(([^)]*)\))?\s*$/;
1007
+
1008
+ /**
1009
+ * @param {unknown} value - The declared `author`, or `undefined`.
1010
+ * @returns {Readonly<{name: string, email?: string, url?: string}>|null}
1011
+ * `null` when the package declares none.
1012
+ */
1013
+ function normalizeAuthor(value) {
1014
+ if (value === undefined) return null;
1015
+ if (typeof value === "string") {
1016
+ const match = AUTHOR_STRING.exec(value.trim());
1017
+ const name = match?.[1]?.trim();
1018
+ if (!match || !name) {
1019
+ fail(
1020
+ "author",
1021
+ 'must be `"Name"`, `"Name <email>"`, `"Name (url)"` or ' +
1022
+ '`"Name <email> (url)"` — npm\'s own `author` forms',
1023
+ );
1024
+ }
1025
+ return Object.freeze({
1026
+ name: /** @type {string} */ (name),
1027
+ ...(match[2] ? { email: match[2] } : {}),
1028
+ ...(match[3] ? { url: match[3] } : {}),
1029
+ });
1030
+ }
1031
+ if (!isPlainObject(value)) {
1032
+ fail("author", "must be a string or an object with `name`, `email` and `url`");
1033
+ }
1034
+ const author = /** @type {Record<string, unknown>} */ (value);
1035
+ rejectUnknownKeys(author, AUTHOR_KEYS, "author.");
1036
+ const name = requireNonEmptyString(author.name, "author.name");
1037
+ return Object.freeze({
1038
+ name,
1039
+ ...(author.email !== undefined ?
1040
+ { email: optionalString(author.email, "author.email") }
1041
+ : {}),
1042
+ ...(author.url !== undefined ? { url: optionalString(author.url, "author.url") } : {}),
1043
+ });
1044
+ }
1045
+
967
1046
  /**
968
1047
  * @param {unknown} value
969
1048
  * @param {string} where Field path used in error messages.
@@ -1504,21 +1583,149 @@ function normalizeSectionMap(value, where) {
1504
1583
  return Object.freeze(out);
1505
1584
  }
1506
1585
 
1586
+ /**
1587
+ * Hugo keys a repository may **not** declare under `site.hugo`, because the
1588
+ * site build generates them and would only overwrite what was written.
1589
+ *
1590
+ * The same rule `DERIVED_MANIFEST_KEYS` states for the manifest, for the same
1591
+ * reason: an authored `baseURL` would look authoritative, sit there unread,
1592
+ * and disagree with the site forever. Each key names where its value comes
1593
+ * from. A dotted key names a nested one, and covers everything beneath it —
1594
+ * `params.brand` refuses `params.brand.logo` too — so `site.hugo` reaches only
1595
+ * what the generator does not write.
1596
+ *
1597
+ * @type {Readonly<Record<string, string>>}
1598
+ */
1599
+ export const DERIVED_HUGO_KEYS = Object.freeze({
1600
+ baseURL: "package.json `homepage`",
1601
+ title: "`packageBuild.manifest.title`",
1602
+ locale: "the organisation's locale, in `engine/site-config.mjs`",
1603
+ publishDir: "`contentPackage`, under the deployment root `build/site`",
1604
+ contentDir: "the fixed content mount, `build/hugo/content`",
1605
+ themesDir: "where `@heroiclands/hugo-theme` is installed",
1606
+ theme: "the installed `@heroiclands/hugo-theme`",
1607
+ disableKinds: "the toolchain, which renders the same kinds on every site",
1608
+ "params.description": "package.json `description`",
1609
+ "params.author": "package.json `author`",
1610
+ "params.cdnBaseURL": "`site.assets`",
1611
+ "params.brand": "the organisation's brand links, in `engine/site-config.mjs`",
1612
+ "params.list": "`site.list`",
1613
+ "params.notfound": "`site.notfound`",
1614
+ "markup.goldmark.renderer.unsafe": "the toolchain, whose pages carry raw HTML",
1615
+ menu: "the navigation `content-build deps fetch` caches from heroiclands.org",
1616
+ });
1617
+
1618
+ /**
1619
+ * The `site.list` block — how a listing page renders.
1620
+ *
1621
+ * @param {unknown} value - The block, or `undefined`.
1622
+ * @returns {Readonly<{shortcodes: boolean}>} It, frozen, with every default filled.
1623
+ */
1624
+ function normalizeSiteList(value) {
1625
+ if (value === undefined) return Object.freeze({ shortcodes: false });
1626
+ if (!isPlainObject(value)) fail("site.list", "must be a mapping");
1627
+ const input = /** @type {Record<string, unknown>} */ (value);
1628
+ rejectUnknownKeys(input, SITE_LIST_KEYS, "site.list.");
1629
+ return Object.freeze({
1630
+ shortcodes: optionalBoolean(input.shortcodes, "site.list.shortcodes", false),
1631
+ });
1632
+ }
1633
+
1634
+ /**
1635
+ * The `site.notfound` block — the wording of the "page not found" page.
1636
+ *
1637
+ * The theme renders the page for every site; what a repository supplies is
1638
+ * the tagline, the noun the body prose calls the site, and the routes back.
1639
+ * `tagline` and `sitenoun` are required once the block is present: a block
1640
+ * declaring only links would render the theme's generic wording above a list
1641
+ * of this site's routes, which reads as two sites.
1642
+ *
1643
+ * @param {unknown} value - The block, or `undefined`.
1644
+ * @returns {Readonly<object>|null} It, frozen; `null` when absent.
1645
+ */
1646
+ function normalizeSiteNotfound(value) {
1647
+ if (value === undefined) return null;
1648
+ if (!isPlainObject(value)) fail("site.notfound", "must be a mapping");
1649
+ const input = /** @type {Record<string, unknown>} */ (value);
1650
+ rejectUnknownKeys(input, SITE_NOTFOUND_KEYS, "site.notfound.");
1651
+ const out = {
1652
+ tagline: requireNonEmptyString(input.tagline, "site.notfound.tagline"),
1653
+ sitenoun: requireNonEmptyString(input.sitenoun, "site.notfound.sitenoun"),
1654
+ };
1655
+ if (input.heroimage !== undefined) {
1656
+ out.heroimage = requireNonEmptyString(input.heroimage, "site.notfound.heroimage");
1657
+ }
1658
+ if (input.links !== undefined) {
1659
+ if (!Array.isArray(input.links)) fail("site.notfound.links", "must be a list");
1660
+ out.links = Object.freeze(
1661
+ input.links.map((link, i) => {
1662
+ const where = `site.notfound.links[${i}]`;
1663
+ if (!isPlainObject(link)) fail(where, "must be a mapping");
1664
+ const entry = /** @type {Record<string, unknown>} */ (link);
1665
+ rejectUnknownKeys(entry, SITE_NOTFOUND_LINK_KEYS, `${where}.`);
1666
+ return Object.freeze({
1667
+ title: requireNonEmptyString(entry.title, `${where}.title`),
1668
+ url: requireNonEmptyString(entry.url, `${where}.url`),
1669
+ text: requireNonEmptyString(entry.text, `${where}.text`),
1670
+ });
1671
+ }),
1672
+ );
1673
+ }
1674
+ return Object.freeze(out);
1675
+ }
1676
+
1677
+ /**
1678
+ * The `site.hugo` block — a mapping deep-merged over the generated Hugo
1679
+ * configuration, last.
1680
+ *
1681
+ * The escape hatch for the key nobody anticipated. Every key the generator
1682
+ * writes is refused here by {@link DERIVED_HUGO_KEYS}, naming its source, so
1683
+ * the block cannot grow into a second configuration file.
1684
+ *
1685
+ * @param {unknown} value - The block, or `undefined`.
1686
+ * @returns {Readonly<Record<string, unknown>>} It, frozen; `{}` when absent.
1687
+ */
1688
+ function normalizeSiteHugo(value) {
1689
+ if (value === undefined) return Object.freeze({});
1690
+ if (!isPlainObject(value)) fail("site.hugo", "must be a mapping");
1691
+ const input = /** @type {Record<string, unknown>} */ (value);
1692
+ for (const [dotted, source] of Object.entries(DERIVED_HUGO_KEYS)) {
1693
+ /** @type {unknown} */
1694
+ let at = input;
1695
+ for (const part of dotted.split(".")) {
1696
+ at = isPlainObject(at) ? /** @type {Record<string, unknown>} */ (at)[part] : undefined;
1697
+ if (at === undefined) break;
1698
+ }
1699
+ if (at !== undefined) {
1700
+ fail(
1701
+ `site.hugo.${dotted}`,
1702
+ `is derived from ${source} and must not be declared — it ` +
1703
+ `would be overwritten, and the two would disagree with ` +
1704
+ `nothing to say so`,
1705
+ );
1706
+ }
1707
+ }
1708
+ return Object.freeze(structuredClone(input));
1709
+ }
1710
+
1507
1711
  /**
1508
1712
  * The `site` section — how this repository frames the website it publishes.
1509
1713
  *
1510
- * Everything here is *framing*: where the Hugo tree is written, what a section
1511
- * is called, which extra trees are published beside the content, and which
1512
- * named pass bundle supplies the repository's own body rewrites. How a page gets
1513
- * its **address** is deliberately not here that is `publish.address`, shared
1514
- * with the link manifest so the two cannot disagree about where a page is.
1714
+ * Everything here is *framing*: what a section is called, which extra trees
1715
+ * are published beside the content, which named pass bundle supplies the
1716
+ * repository's own body rewrites, and the residue of the generated Hugo
1717
+ * configuration that is genuinely this repository's own. Where the Hugo tree
1718
+ * is written is not a choice: `content-build site` writes it under
1719
+ * `build/hugo/`, and a `site.out` is refused by name. How a page gets its
1720
+ * **address** is deliberately not here either — that is `publish.address`,
1721
+ * shared with the link manifest so the two cannot disagree about where a
1722
+ * page is.
1515
1723
  *
1516
1724
  * @param {unknown} value - The `site` block, or `undefined`.
1517
1725
  * @returns {Readonly<object>} It, frozen, with every default filled.
1518
1726
  */
1519
1727
  function normalizeSite(value) {
1520
1728
  const empty = Object.freeze({
1521
- out: "",
1522
1729
  base: "",
1523
1730
  assets: "",
1524
1731
  packages: Object.freeze([]),
@@ -1529,10 +1736,23 @@ function normalizeSite(value) {
1529
1736
  pass: "",
1530
1737
  passOptions: Object.freeze({}),
1531
1738
  backfillSections: false,
1739
+ list: Object.freeze({ shortcodes: false }),
1740
+ notfound: null,
1741
+ hugo: Object.freeze({}),
1532
1742
  });
1533
1743
  if (value === undefined) return empty;
1534
1744
  if (!isPlainObject(value)) fail("site", "must be a mapping");
1535
1745
  const input = /** @type {Record<string, unknown>} */ (value);
1746
+ // Refused by name, ahead of the vocabulary check: the useful thing to say
1747
+ // is not "no such key" but that the location is fixed.
1748
+ if (input.out !== undefined) {
1749
+ fail(
1750
+ "site.out",
1751
+ "is retired — the site build writes its content mount at " +
1752
+ "`build/hugo/content`, beside the generated `hugo.toml`, and " +
1753
+ "the location is not configurable. Remove the key",
1754
+ );
1755
+ }
1536
1756
  rejectUnknownKeys(input, SITE_KEYS, "site.");
1537
1757
 
1538
1758
  const trees = [];
@@ -1574,7 +1794,6 @@ function normalizeSite(value) {
1574
1794
  }
1575
1795
 
1576
1796
  return Object.freeze({
1577
- out: input.out === undefined ? "" : requireNonEmptyString(input.out, "site.out"),
1578
1797
  base: input.base === undefined ? "" : requireNonEmptyString(input.base, "site.base"),
1579
1798
  assets: normalizeSiteAssets(input.assets),
1580
1799
  packages: Object.freeze(packages),
@@ -1588,6 +1807,9 @@ function normalizeSite(value) {
1588
1807
  Object.freeze({})
1589
1808
  : Object.freeze({ ...input.passOptions }),
1590
1809
  backfillSections: optionalBoolean(input.backfillSections, "site.backfillSections", false),
1810
+ list: normalizeSiteList(input.list),
1811
+ notfound: normalizeSiteNotfound(input.notfound),
1812
+ hugo: normalizeSiteHugo(input.hugo),
1591
1813
  });
1592
1814
  }
1593
1815
 
@@ -2493,6 +2715,11 @@ export function defineConfig(config) {
2493
2715
  rootDir,
2494
2716
  contentPackage: requireContentPackage(input.contentPackage, docEntryTypes),
2495
2717
  foundryPackage,
2718
+ // `package.json`'s own address and byline. `homepage` is checked by
2719
+ // `checkHomepage` in `config.mjs`.
2720
+ homepage:
2721
+ input.homepage === undefined ? null : requireNonEmptyString(input.homepage, "homepage"),
2722
+ author: normalizeAuthor(input.author),
2496
2723
  packageKind: /** @type {PackageKind} */ (packageKind),
2497
2724
  // Foundry serves a package's files from `<kind>/<id>/`, so this is the
2498
2725
  // one place `systems/sohl` (or `modules/sohl-thalorna`) is spelled.
package/docs/api.md CHANGED
@@ -621,8 +621,7 @@ Publishing a content tree as a website. Compiling a content tree into compendium
621
621
  | `writeSectionLandings` | `function writeSectionLandings(outRoot,` | {number} How many landings were written. | Writes the Hugo sections a published tree declares. |
622
622
  | `pluralTitle` | `function pluralTitle(name)` | {string} The display title. | A section landing's title, from its directory name — `macro` → `Macros`. |
623
623
  | `resolveSitePass` | `function resolveSitePass(name, options)` | {{beforeLinks?: Function, afterLinks?: Function}} The bundle. | Resolves `site.pass` to its bundle. |
624
- | `resolveOutputRoot` | `function resolveOutputRoot(rootDir, out)` | {string} The absolute output root. | The output root, having established that it is safe to delete. |
625
- | `buildSite` | `buildSite({ config, outRoot, sqlTables })` | {{gates: object, stats: object\|null, tableErrors: object[], wikiErrors: object[], manifests: object\|null}} | Builds a Hugo content tree from a content tree, and reports what it found. |
624
+ | `buildSite` | `buildSite({ config, sqlTables })` | {{gates: object, stats: object\|null, tableErrors: object[], wikiErrors: object[], manifests: object\|null}} | Builds a Hugo content tree from a content tree, and reports what it found. |
626
625
 
627
626
  ### `engine.contentLint`
628
627
 
@@ -1269,6 +1268,7 @@ const config = defineConfig({
1269
1268
  | `SITE_MODES` | `const SITE_MODES` | — | reading the publishing modes `publish.site` may name, weakest first |
1270
1269
  | `DERIVED_SYSTEM_VERSION` | `const DERIVED_SYSTEM_VERSION` | — | the loader-only symbol key `defineConfig` uses internally to receive a resolved system version; not something a configuration author writes |
1271
1270
  | `publishesContentPages` | `publishesContentPages(config)` | `boolean` | checking whether a resolved configuration publishes the pages its content tree compiles to — the one question the site build and the content index both need answered identically |
1271
+ | `DERIVED_HUGO_KEYS` | `const DERIVED_HUGO_KEYS` | — | reading which Hugo keys `site.hugo` may not declare because the site build generates them, each naming its source (declaring one is an error naming the key); a dotted key covers everything beneath it |
1272
1272
 
1273
1273
  ## `./config`
1274
1274
 
@@ -1282,11 +1282,12 @@ const config = loadPackageBuildConfig();
1282
1282
  console.log(config.stageDir);
1283
1283
  ```
1284
1284
 
1285
- | Export | Signature | Returns | Use it when |
1286
- | --------------------------- | ----------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
1287
- | `DERIVED_MANIFEST_KEYS` | `const DERIVED_MANIFEST_KEYS` | — | reading which manifest keys a repository may not declare because the build derives them (declaring one is an error naming the key) |
1288
- | `resolvePackageBuildConfig` | `resolvePackageBuildConfig(shared)` | `Readonly<PackageBuildConfig>` | validating the `packageBuild:` section from an already-loaded shared configuration — the pure half, usable without touching disk |
1289
- | `loadPackageBuildConfig` | `loadPackageBuildConfig()` | `Readonly<PackageBuildConfig>` | reading and validating the repository's resolved package-build configuration from disk, read fresh on each call rather than cached at import |
1285
+ | Export | Signature | Returns | Use it when |
1286
+ | --------------------------- | ----------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1287
+ | `DERIVED_MANIFEST_KEYS` | `const DERIVED_MANIFEST_KEYS` | — | reading which manifest keys a repository may not declare because the build derives them (declaring one is an error naming the key) |
1288
+ | `resolvePackageBuildConfig` | `resolvePackageBuildConfig(shared)` | `Readonly<PackageBuildConfig>` | validating the `packageBuild:` section from an already-loaded shared configuration — the pure half, usable without touching disk |
1289
+ | `loadPackageBuildConfig` | `loadPackageBuildConfig()` | `Readonly<PackageBuildConfig>` | reading and validating the repository's resolved package-build configuration from disk, read fresh on each call rather than cached at import |
1290
+ | `checkHomepage` | `checkHomepage(homepage, contentPackage)` | `void` | validating a resolved `homepage` against `contentPackage` — called by `content-build site` before the generated `baseURL` is written, not by `resolvePackageBuildConfig` itself |
1290
1291
 
1291
1292
  ## `./prettier`
1292
1293
 
package/docs/commands.md CHANGED
@@ -987,17 +987,28 @@ content-build deps fetch [--from <zip|dir>] [--id <id>]
987
987
 
988
988
  **DESCRIPTION**
989
989
 
990
- The only action is `fetch`, which fills the **content index** of every
991
- declared dependency, and the **item catalogue** of those that additionally
992
- declare `itemCatalog: true`. Its own command rather than a step of
993
- `package compile`, so a compile never reaches the network — a build that
994
- downloads silently is not reproducible and hides a dependency's version
995
- change behind a passing run. `--from` fills the cache from a locally built
996
- artifact a package zip, or the directory it was built from instead of a
997
- release, which is what makes testing a dependency change against its
998
- consumers possible before any of it ships; `--id` names which declared
999
- dependency `--from` supplies, needed only when the repository declares more
1000
- than one. Writes into the configured foreign-cache directory.
990
+ The only action is `fetch`, which fills three caches under `build/cache/`:
991
+ the **content index** of every declared dependency, the **item catalogue**
992
+ of those that additionally declare `itemCatalog: true`, and the **site
993
+ navigation** — `https://www.heroiclands.org/nav.json`, the header menu
994
+ every package site renders, fetched for every package because every package
995
+ publishes a site. Its own command rather than a step of `package compile`
996
+ or `site`, so neither reaches the network a build that downloads silently
997
+ is not reproducible and hides a dependency's version change behind a
998
+ passing run. Each cache is stamped complete only once its fetch finishes,
999
+ so a half-finished one reads as cold.
1000
+
1001
+ The navigation is fetched first, because every package needs it and it
1002
+ depends on nothing a repository declares — so a dependency whose release
1003
+ cannot be read stops the run with the navigation already cached.
1004
+
1005
+ `--from` fills the cache from a locally built artifact — a package zip, or
1006
+ the directory it was built from — instead of a release, which is what makes
1007
+ testing a dependency change against its consumers possible before any of it
1008
+ ships; `--id` names which declared dependency `--from` supplies, needed only
1009
+ when the repository declares more than one. `--from` fills that one
1010
+ dependency's caches and nothing else: the navigation is not an artifact of
1011
+ any dependency, and is fetched by a plain `deps fetch`.
1001
1012
 
1002
1013
  **OPTIONS**
1003
1014
 
@@ -1016,6 +1027,7 @@ including when the repository declares no dependencies at all.
1016
1027
 
1017
1028
  ```
1018
1029
  $ content-build deps fetch
1030
+ […] Fetched the site navigation to build/cache/navigation/nav.json.
1019
1031
  […] No relationship declares `itemCatalog: true`; nothing to fetch.
1020
1032
  […] This package declares no dependencies.
1021
1033
 
@@ -1025,7 +1037,7 @@ $ content-build deps fetch --from build/dist/module.zip
1025
1037
 
1026
1038
  **SEE ALSO**
1027
1039
 
1028
- `content-build addresses diff`, [Configuration](configuration.md).
1040
+ `content-build site`, `content-build addresses diff`, [Configuration](configuration.md).
1029
1041
 
1030
1042
  ### `content-build docs item-fields`
1031
1043
 
@@ -1515,54 +1527,78 @@ $ content-build content-index
1515
1527
 
1516
1528
  **NAME**
1517
1529
 
1518
- Publish the content tree as a Hugo content mount.
1530
+ Build the Hugo source tree from the content tree.
1519
1531
 
1520
1532
  **SYNOPSIS**
1521
1533
 
1522
1534
  ```
1523
- content-build site [--out <dir>]
1535
+ content-build site
1524
1536
  ```
1525
1537
 
1526
1538
  **DESCRIPTION**
1527
1539
 
1528
- Publishes the content tree as a Hugo content mount — the sibling of
1540
+ Writes the whole Hugo source tree under `build/hugo/` — the sibling of
1529
1541
  `package compile`: the same tree, rendered as pages instead of compiled
1530
1542
  into packs. Everything a consumer would otherwise write for itself happens
1531
1543
  here: the walk, address derivation, the address index, table expansion,
1532
- wikilink resolution, code-fence protection, the foreign-manifest merge and
1533
- the section-landing backfill. Every gate is checked and reported, and the
1534
- run stops at the first that fires, ordered so the report names the cause
1544
+ wikilink resolution, code-fence protection, the foreign-manifest merge, the
1545
+ section-landing backfill, and the Hugo configuration itself. The consumer's
1546
+ script then runs Hugo over the tree `hugo --source build/hugo` and this
1547
+ command never does.
1548
+
1549
+ Three things are written, and nothing outside `build/`:
1550
+
1551
+ - `build/hugo/hugo.toml`, generated on every run from `package.json`
1552
+ (`homepage`, `description`, `author`), `package-build.config.yaml`
1553
+ (`packageBuild.manifest.title`, `site.assets`, `site.list`,
1554
+ `site.notfound`, `site.hugo`), the organisation's constants, the installed
1555
+ `@heroiclands/hugo-theme`'s location, and the navigation `deps fetch`
1556
+ cached. Every value's source is listed under
1557
+ [the generated Hugo configuration](configuration.md#the-generated-hugo-configuration).
1558
+ - `build/hugo/content/`, the content mount — the homepage at its root, and
1559
+ the content tree's pages below `publish.address.prefix`. Wiped on every
1560
+ run.
1561
+ - `publishDir` pointing Hugo at `build/site/<contentPackage>/`, the
1562
+ deployment root `package-build site-root` writes beside. Nothing Hugo
1563
+ reads lands in what is published.
1564
+
1565
+ The configuration's sources are read before the output tree is touched, so
1566
+ a missing `homepage`, a cold navigation cache or an uninstalled theme fails
1567
+ with the previous site intact. Every gate is then checked and reported, and
1568
+ the run stops at the first that fires, ordered so the report names the cause
1535
1569
  rather than its symptoms — an unusable dependency manifest, reported after
1536
1570
  the links that failed because of it, would otherwise read as a pile of
1537
- broken notes. Reads the content tree named by `paths.content`; writes into
1538
- `--out`, defaulting to the configured `site.out`, which is wiped on every
1539
- run.
1571
+ broken notes. Reads the content tree named by `paths.content`.
1540
1572
 
1541
1573
  **OPTIONS**
1542
1574
 
1543
- | Option | Type | Default | Description |
1544
- | ------- | ------ | ------------------------- | -------------------------------------------------------- |
1545
- | `--out` | string | the configured `site.out` | Write the mount here instead of the configured location. |
1575
+ None.
1546
1576
 
1547
1577
  **EXIT STATUS**
1548
1578
 
1549
- 1 if `site.out` is unset and `--out` is not given (an unset output would
1550
- resolve to the repository root, which this command refuses to wipe). 1 if
1551
- any gate fires no homepage or two competing for it, a frontmatter
1552
- wikilink, an address that cannot be derived, a stale or unaddressable
1553
- dependency manifest, an address published twice, a table that failed to
1554
- expand, or a dead wikilink. Otherwise 0.
1579
+ 1 if `package.json` declares no `homepage`, or one that does not end
1580
+ `/<contentPackage>/`; if `packageBuild.manifest.title` is undeclared; if the
1581
+ navigation has not been fetched (`content-build deps fetch` fills the cache
1582
+ and is named in the message); or if `@heroiclands/hugo-theme` is not
1583
+ installed. 1 if any gate fires no homepage or two competing for it, a
1584
+ frontmatter wikilink, an address that cannot be derived, a stale or
1585
+ unaddressable dependency manifest, an address published twice, a table that
1586
+ failed to expand, or a dead wikilink. Otherwise 0.
1555
1587
 
1556
1588
  **EXAMPLES**
1557
1589
 
1558
1590
  ```
1559
1591
  $ content-build site
1560
- […] wrote 1 homepage(s) + 1 content page(s) + 0 tree page(s) + 0 landing(s) to build/site
1592
+ […] wrote 1 homepage(s) + 1 content page(s) + 0 tree page(s) + 0 landing(s) to build/hugo/content
1593
+ […] wrote build/hugo/hugo.toml
1594
+
1595
+ $ content-build site
1596
+ […] ERROR: the site navigation has not been fetched. Run `content-build deps fetch` first.
1561
1597
  ```
1562
1598
 
1563
1599
  **SEE ALSO**
1564
1600
 
1565
- `content-build package <action> [pack] [entry]`, `content-build pdf`, `content-build content-index [root]`,
1601
+ `content-build deps fetch`, `package-build site-root`, `content-build package <action> [pack] [entry]`, `content-build pdf`, `content-build content-index [root]`,
1566
1602
  [Diagnostics](diagnostics.md), [Configuration](configuration.md).
1567
1603
 
1568
1604
  ### `content-build pdf`