@heroiclands/package-build 22.3.0 → 22.4.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.
@@ -80,7 +80,7 @@ import {
80
80
  formatUnaddressableFinding,
81
81
  } from "../engine/metadata-index.mjs";
82
82
  import { fetchNavigation, generateHugoConfig, writeHugoConfig } from "../engine/site-config.mjs";
83
- import { renderItemFieldReference } from "../engine/field-reference.mjs";
83
+ import { renderItemFieldReference, renderItemFieldsPage } from "../engine/field-reference.mjs";
84
84
  import { lintContentTree } from "../engine/content-lint.mjs";
85
85
  import { lintContentCharset } from "../engine/content-charset.mjs";
86
86
  import { lintContentHtml } from "../engine/content-html.mjs";
@@ -284,6 +284,14 @@ const argv = yargs(hideBin(process.argv))
284
284
  * second implementation of the comparison. Staleness is a property of the whole
285
285
  * generated file, so there is no line to name.
286
286
  *
287
+ * **A destination under the content tree gets a note envelope**, so the walk
288
+ * that collects every note by its `type:` picks this one up too, rather than
289
+ * silently dropping it — `type: doc`, `subType: reference`, a `shortcode`
290
+ * derived from the destination's basename, `name.full` from the title, and
291
+ * `pack: none`; `docs.itemFields.frontmatter` deep-merges over it. `--check`
292
+ * compares the whole file, envelope included. A destination outside the
293
+ * content tree gets the page body alone, as before.
294
+ *
287
295
  * `--out` and `--title` still override, for a one-off render.
288
296
  *
289
297
  * @returns {object} The yargs command module.
@@ -332,13 +340,27 @@ function docsCommand() {
332
340
  const spec = config.docs?.itemFields ?? {};
333
341
  const destination =
334
342
  argv.out ?? (spec.out ? path.resolve(config.rootDir, spec.out) : null);
343
+ const pageTitle = title ?? spec.title ?? "Item Note Frontmatter";
335
344
 
336
- const page = `${renderItemFieldReference({
337
- ...((title ?? spec.title) ? { title: title ?? spec.title } : {}),
345
+ const body = `${renderItemFieldReference({
346
+ title: pageTitle,
338
347
  ...(spec.preamble ? { preamble: spec.preamble } : {}),
339
348
  generatedBy: "`content-build docs item-fields`",
340
349
  config,
341
350
  })}\n`;
351
+ // A page filed under the content tree is walked for its
352
+ // `type:` like any other note, so it needs the envelope; one
353
+ // filed anywhere else — a repository's own `docs/` — is not,
354
+ // and gets exactly the body it always did.
355
+ const page =
356
+ destination ?
357
+ renderItemFieldsPage(body, {
358
+ title: pageTitle,
359
+ destination,
360
+ contentRoot: config.paths.content,
361
+ frontmatter: spec.frontmatter,
362
+ })
363
+ : body;
342
364
 
343
365
  if (check) {
344
366
  if (!destination) {
@@ -1484,18 +1506,17 @@ function pdfCommand() {
1484
1506
  * The sibling of `package compile`: the same tree, rendered as pages instead of
1485
1507
  * compiled into packs. Everything a consumer would otherwise write for itself —
1486
1508
  * the walk, the address derivation, the address index, table expansion,
1487
- * wikilink resolution, code-fence protection, the foreign-manifest merge and
1488
- * the section-landing backfill — happens here, from configuration. So does the
1489
- * Hugo configuration: the whole source tree Hugo reads lands under
1490
- * `build/hugo/`, and the consumer's script runs Hugo over it.
1509
+ * wikilink resolution, code-fence protection and the foreign-manifest merge
1510
+ * happens here, from configuration. So does the Hugo configuration: the whole
1511
+ * source tree Hugo reads lands under `build/hugo/`, and the consumer's script
1512
+ * runs Hugo over it.
1491
1513
  *
1492
1514
  * **The Hugo configuration is generated before anything is written.** Its
1493
1515
  * sources — `package.json`'s `homepage`, the cached navigation, the installed
1494
1516
  * theme — are each a way the build can fail, and failing before the output
1495
- * tree is cleared leaves the last good site in place to be looked at. It is
1496
- * generated again once the site walk completes, because whether the site
1497
- * emits taxonomy pages is read from the walk whether any note carries
1498
- * `tags:` — and that is not known until then.
1517
+ * tree is cleared leaves the last good site in place to be looked at. Nothing
1518
+ * the walk reads changes it: a site renders its homepage and its pages, and
1519
+ * the kinds Hugo disables are the same on every site.
1499
1520
  *
1500
1521
  * **Each gate is reported and the run stops at the first that fires.** They are
1501
1522
  * ordered so the report names the cause rather than its symptoms: an unusable
@@ -1513,14 +1534,12 @@ function siteCommand() {
1513
1534
  handler: async () => {
1514
1535
  try {
1515
1536
  const config = loadPackConfig();
1516
- // Generated once before the walk, purely to fail fast on a
1517
- // missing or mismatched source — `homepage`, the manifest
1518
- // title, the cached navigation, the installed theme — while
1519
- // the last good site is still in place to be looked at. Its
1520
- // `disableKinds`/`taxonomies`/`outputs` are provisional: only
1521
- // the site walk below knows whether any note carries `tags:`,
1522
- // so the value actually written is regenerated after it runs.
1523
- generateHugoConfig(config);
1537
+ // Generated before the walk, to fail fast on a missing or
1538
+ // mismatched source — `homepage`, the manifest title, the
1539
+ // cached navigation, the installed theme — while the last
1540
+ // good site is still in place to be looked at. Written only
1541
+ // once the walk has succeeded.
1542
+ const hugo = generateHugoConfig(config);
1524
1543
  const result = buildSite({
1525
1544
  config,
1526
1545
  sqlTables: await prepareTreeSqlTables(config.paths.content, {
@@ -1631,11 +1650,9 @@ function siteCommand() {
1631
1650
  const s = result.stats;
1632
1651
  log.info(
1633
1652
  `wrote ${s.homepages ?? 0} homepage(s) + ` +
1634
- `${s.content ?? 0} content page(s) + ` +
1635
- `${s.tree ?? 0} tree page(s) + ${s.landings} ` +
1636
- `landing(s) to ${path.relative(process.cwd(), s.out)}`,
1653
+ `${s.content ?? 0} content page(s) to ` +
1654
+ `${path.relative(process.cwd(), s.out)}`,
1637
1655
  );
1638
- const hugo = generateHugoConfig(config, { hasTags: result.hasTags });
1639
1656
  const { file } = writeHugoConfig(config, hugo);
1640
1657
  log.info(`wrote ${path.relative(process.cwd(), file)}`);
1641
1658
  } catch (err) {
@@ -455,15 +455,16 @@ function manifestCommand() {
455
455
  * `package-build site-root` — the deployment's root files.
456
456
  *
457
457
  * Hugo owns everything under the `/<package>/` prefix; this owns what sits
458
- * beside it, which is the pair Cloudflare Pages reads from the uploaded
459
- * directory and nowhere else.
458
+ * beside it, which is what Cloudflare Pages reads from the uploaded directory
459
+ * and nowhere else: `_headers`, and no `_redirects`, since the prefix root is
460
+ * the homepage.
460
461
  *
461
462
  * @returns {object} The yargs command.
462
463
  */
463
464
  function siteRootCommand() {
464
465
  return {
465
466
  command: "site-root",
466
- describe: "Write the deployment's _headers and _redirects",
467
+ describe: "Write the deployment's _headers",
467
468
  builder: (y) =>
468
469
  y.option("out", {
469
470
  type: "string",
@@ -256,10 +256,9 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
256
256
  * and the default.
257
257
  *
258
258
  * - `homepage` — the authored homepage, and **no other page**. The content tree
259
- * is not walked for pages, `site.sections` / `site.landing` emit nothing,
260
- * and nothing serves a page for its addresses.
261
- * - `content` — the homepage *plus* every page the content tree publishes: the
262
- * knowledgebase and the section landings.
259
+ * is not walked for pages, and nothing serves a page for its addresses.
260
+ * - `content` the homepage *plus* every page the content tree publishes, one
261
+ * per note.
263
262
  *
264
263
  * **Homepage-only is a first-class mode, not an accommodation.**
265
264
  * `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
@@ -528,6 +527,11 @@ export function publishesContentPages(config) {
528
527
  * Without it the page goes to stdout.
529
528
  * @property {string[]} [preamble] Lines between the generated banner and the
530
529
  * first table. Markdown, emitted verbatim.
530
+ * @property {Record<string, unknown>} [frontmatter] Further note frontmatter,
531
+ * deep-merged over the generated envelope
532
+ * (`type: doc`, `subType: reference`,
533
+ * `shortcode`, `name.full`, `pack: none`)
534
+ * when `out` is under the content tree.
531
535
  */
532
536
 
533
537
  /**
@@ -761,23 +765,17 @@ const SITE_KEYS = [
761
765
  "assets",
762
766
  "description",
763
767
  "packages",
764
- "sections",
765
- "landing",
766
768
  "pass",
767
769
  "passOptions",
768
- "backfillSections",
769
- "list",
770
770
  "notfound",
771
771
  "hugo",
772
772
  ];
773
- const SITE_LIST_KEYS = ["shortcodes"];
774
773
  const SITE_NOTFOUND_KEYS = ["tagline", "sitenoun", "heroimage", "links"];
775
774
  const SITE_NOTFOUND_LINK_KEYS = ["title", "url", "text"];
776
775
  const PDF_KEYS = ["title", "subtitle", "document", "out", "front", "fonts", "iconFonts", "binary"];
777
776
  const PDF_FONT_KEYS = ["serif", "sans", "mono", "path"];
778
777
  const EMPTY_PDF_FONTS = Object.freeze({ serif: "", sans: "", mono: "", path: "" });
779
- const SECTION_META_KEYS = ["title", "banner", "description", "listType", "listSubType"];
780
- const DOC_PAGE_KEYS = ["title", "out", "preamble"];
778
+ const DOC_PAGE_KEYS = ["title", "out", "preamble", "frontmatter"];
781
779
  const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
782
780
  const RELATIONSHIP_KEYS = [
783
781
  "id",
@@ -1435,6 +1433,12 @@ function normalizeDocPage(value, where) {
1435
1433
  }),
1436
1434
  );
1437
1435
  }
1436
+ if (input.frontmatter !== undefined) {
1437
+ if (!isPlainObject(input.frontmatter)) {
1438
+ fail(`${where}.frontmatter`, "must be a mapping");
1439
+ }
1440
+ out.frontmatter = deepFreeze({ .../** @type {object} */ (input.frontmatter) });
1441
+ }
1438
1442
  return Object.freeze(out);
1439
1443
  }
1440
1444
 
@@ -1454,102 +1458,6 @@ function normalizeDocs(value) {
1454
1458
  });
1455
1459
  }
1456
1460
 
1457
- /**
1458
- * One section's landing metadata — what a section says about itself on the
1459
- * `_index.md` this build generates for it.
1460
- *
1461
- * A generated landing is the *only* place a section can speak, and it
1462
- * is the only place a section **exists**: a content page is addressed
1463
- * `(type, shortcode)` and written flat under the mount, so no page creates a
1464
- * directory and nothing else makes `<prefix><section>/` answer. This is
1465
- * therefore the whole vocabulary, and it is deliberately a **closed** one.
1466
- *
1467
- * The alternative — passing whatever a section declared straight through, as
1468
- * `site.landing` does — was weighed and refused. `landing` is written once, for
1469
- * the mount, and its keys are one landing template's own; a section entry is
1470
- * written fourteen to twenty times per build against a contract every package
1471
- * and every section shares. Unbounded there, a mistyped `descrption:` publishes
1472
- * into front matter, is read by nobody, and says nothing to anyone — which is
1473
- * the same failure, moved one step downstream where no build can
1474
- * see it. So the keys are named here, and the writers emit what this produced
1475
- * rather than transcribing a second list of their own.
1476
- *
1477
- * `banner` and `description` are optional — the hero images are external assets
1478
- * and not every section has one, and a section may reasonably have nothing to
1479
- * add to its title. Each is left off entirely rather than written as
1480
- * `undefined`, which is not a value YAML can carry.
1481
- *
1482
- * **`listType` / `listSubType` say what the section lists.** A section's
1483
- * directory holds nothing but the `_index.md` written here, so a layout
1484
- * reading Hugo's `.Pages` finds
1485
- * no members and renders an empty landing. The membership survives in this map
1486
- * and nowhere a theme can reach it, so the landing states it and a layout
1487
- * substitutes the equivalent `site.RegularPages` query — the same one `sohl`'s
1488
- * catalog layouts already run, which is why `sohl`'s landings never broke.
1489
- *
1490
- * They are two keys of their own rather than `type` / `subType` because `type`
1491
- * on an `_index.md` is **Hugo's own layout selector**: verified against Hugo
1492
- * 0.165, a section landing carrying `type: doc` renders through
1493
- * `layouts/doc/list.html` rather than the default list template, so spelling
1494
- * the content type there would silently change which template serves the
1495
- * landing. (This build already uses that behaviour deliberately, for the
1496
- * mount's own landing.)
1497
- *
1498
- * Both are checked as **address segments**, which is the trap this came from:
1499
- * a section is named for the URL a consumer chose and a subType is an address
1500
- * segment, and the two need not agree — `/sohl/kb/user-guide/` is the section,
1501
- * `userguide` the subType. Copying the section's name into the
1502
- * declaration would select no page at all, and an empty landing reported by
1503
- * nobody is the failure being fixed. A `listSubType` with no `listType` is
1504
- * refused for the same reason: a subType is only distinguishing *within* a
1505
- * type — `rules`, `userguide` and `reference` are all `doc` — so alone it names
1506
- * no query.
1507
- *
1508
- * @param {unknown} value - The declared entry.
1509
- * @param {string} where - Dotted path, for the error.
1510
- * @returns {Readonly<{title: string, banner?: string, description?: string,
1511
- * listType?: string, listSubType?: string}>}
1512
- */
1513
- function normalizeSectionMeta(value, where) {
1514
- if (!isPlainObject(value)) fail(where, "must be a mapping");
1515
- const input = /** @type {Record<string, unknown>} */ (value);
1516
- rejectUnknownKeys(input, SECTION_META_KEYS, `${where}.`);
1517
- const out = { title: requireNonEmptyString(input.title, `${where}.title`) };
1518
- if (input.banner !== undefined) {
1519
- out.banner = requireNonEmptyString(input.banner, `${where}.banner`);
1520
- }
1521
- if (input.description !== undefined) {
1522
- out.description = requireNonEmptyString(input.description, `${where}.description`);
1523
- }
1524
- for (const key of ["listType", "listSubType"]) {
1525
- if (input[key] === undefined) continue;
1526
- const segment = requireNonEmptyString(input[key], `${where}.${key}`);
1527
- if (!isAddressSegment(segment)) {
1528
- fail(
1529
- `${where}.${key}`,
1530
- `is \`${segment}\`, which is not lowercase alphanumeric. It names a ` +
1531
- "content type or subType, and those are address segments " +
1532
- `(${ADDRESS_SEGMENT_PATTERN.source}) — not the section's ` +
1533
- "own name, which is a URL this site chose and need not " +
1534
- "match (`user-guide` is the section, `userguide` the " +
1535
- "subType). A value no page carries selects nothing and " +
1536
- "leaves the landing empty",
1537
- );
1538
- }
1539
- out[key] = segment;
1540
- }
1541
- if (out.listSubType !== undefined && out.listType === undefined) {
1542
- fail(
1543
- `${where}.listSubType`,
1544
- "is declared without a `listType`. A subType tells pages apart " +
1545
- "only within a type — `rules`, `userguide` and `reference` " +
1546
- "are all `doc` — so on its own it names no query for a layout " +
1547
- "to run",
1548
- );
1549
- }
1550
- return Object.freeze(out);
1551
- }
1552
-
1553
1461
  /**
1554
1462
  * The asset host the website resolves a pathname against.
1555
1463
  *
@@ -1609,23 +1517,6 @@ function normalizeSiteDescription(value) {
1609
1517
  return description;
1610
1518
  }
1611
1519
 
1612
- /**
1613
- * A map of section name → landing metadata.
1614
- *
1615
- * @param {unknown} value - The declared mapping.
1616
- * @param {string} where - Dotted path, for the error.
1617
- * @returns {Readonly<Record<string, object>>}
1618
- */
1619
- function normalizeSectionMap(value, where) {
1620
- if (value === undefined) return Object.freeze({});
1621
- if (!isPlainObject(value)) fail(where, "must be a mapping");
1622
- const out = {};
1623
- for (const [name, meta] of Object.entries(/** @type {Record<string, unknown>} */ (value))) {
1624
- out[name] = normalizeSectionMeta(meta, `${where}.${name}`);
1625
- }
1626
- return Object.freeze(out);
1627
- }
1628
-
1629
1520
  /**
1630
1521
  * Hugo keys a repository may **not** declare under `site.hugo`, because the
1631
1522
  * site build generates them and would only overwrite what was written.
@@ -1647,35 +1538,24 @@ export const DERIVED_HUGO_KEYS = Object.freeze({
1647
1538
  contentDir: "the fixed content mount, `build/hugo/content`",
1648
1539
  themesDir: "where `@heroiclands/hugo-theme` is installed",
1649
1540
  theme: "the installed `@heroiclands/hugo-theme`",
1650
- disableKinds: "whether any note in the tree carries `tags:`, which the site walk discovers",
1651
- taxonomies: "whether any note in the tree carries `tags:`, which the site walk discovers",
1652
- outputs: "whether any note in the tree carries `tags:`, which the site walk discovers",
1541
+ disableKinds:
1542
+ "the toolchain, which renders a site as its homepage and its pages: " +
1543
+ "`section`, `taxonomy`, `term` and `RSS` are disabled on every site",
1544
+ taxonomies:
1545
+ "the toolchain, which renders a site as its homepage and its pages: no " +
1546
+ "taxonomy is declared, because `taxonomy` and `term` are disabled kinds",
1547
+ outputs:
1548
+ "the toolchain, which renders a site as its homepage and its pages: no " +
1549
+ "output format is declared, because every listing kind is disabled",
1653
1550
  "params.description": "`site.description`",
1654
1551
  "params.author": "package.json `author`",
1655
1552
  "params.cdnBaseURL": "`site.assets`",
1656
1553
  "params.brand": "the organisation's brand links, in `engine/site-config.mjs`",
1657
- "params.list": "`site.list`",
1658
1554
  "params.notfound": "`site.notfound`",
1659
1555
  "markup.goldmark.renderer.unsafe": "the toolchain, whose pages carry raw HTML",
1660
1556
  menu: "the navigation `content-build deps fetch` caches from heroiclands.org",
1661
1557
  });
1662
1558
 
1663
- /**
1664
- * The `site.list` block — how a listing page renders.
1665
- *
1666
- * @param {unknown} value - The block, or `undefined`.
1667
- * @returns {Readonly<{shortcodes: boolean}>} It, frozen, with every default filled.
1668
- */
1669
- function normalizeSiteList(value) {
1670
- if (value === undefined) return Object.freeze({ shortcodes: false });
1671
- if (!isPlainObject(value)) fail("site.list", "must be a mapping");
1672
- const input = /** @type {Record<string, unknown>} */ (value);
1673
- rejectUnknownKeys(input, SITE_LIST_KEYS, "site.list.");
1674
- return Object.freeze({
1675
- shortcodes: optionalBoolean(input.shortcodes, "site.list.shortcodes", false),
1676
- });
1677
- }
1678
-
1679
1559
  /**
1680
1560
  * The `site.notfound` block — the wording of the "page not found" page.
1681
1561
  *
@@ -1753,25 +1633,59 @@ function normalizeSiteHugo(value) {
1753
1633
  return Object.freeze(structuredClone(input));
1754
1634
  }
1755
1635
 
1636
+ /**
1637
+ * The keys that asked the site build to write an index between the homepage
1638
+ * and the pages, each refused with the one message.
1639
+ *
1640
+ * A site is its homepage and its pages. Each of these keys asks for a
1641
+ * generated listing between them — `sections` (with `listType` and
1642
+ * `listSubType` inside an entry) a type-wide alphabetical one per entry,
1643
+ * `backfillSections` one for every other directory under the mount, `landing`
1644
+ * the mount's own, `list` how such a listing renders. None of those is a page
1645
+ * anyone chose the contents of, and every one of them is authored instead: a
1646
+ * `doc` note, addressed by its shortcode, carrying a content table over the
1647
+ * notes it introduces, with the columns it chooses.
1648
+ *
1649
+ * Refused rather than ignored, for the reason {@link RETIRED_ADDRESS_KEYS}
1650
+ * gives: a key left ignored reads to its author as though it still works.
1651
+ *
1652
+ * @type {readonly string[]}
1653
+ */
1654
+ const SITE_INDEX_KEYS = Object.freeze(["sections", "landing", "backfillSections", "list"]);
1655
+
1656
+ /**
1657
+ * The message every key in {@link SITE_INDEX_KEYS} is refused with.
1658
+ *
1659
+ * @type {string}
1660
+ */
1661
+ const SITE_INDEX_MESSAGE =
1662
+ "is retired — a site is its homepage and its pages, and any index between " +
1663
+ "them is a `doc` note: write one with `type: doc`, a `shortcode` and " +
1664
+ "`pack: none`, carrying a content table over the notes it lists, and link " +
1665
+ "it from the homepage. Nothing is generated between the homepage and the " +
1666
+ "pages, so delete the key";
1667
+
1756
1668
  /**
1757
1669
  * The `site` section — how this repository frames the website it publishes.
1758
1670
  *
1759
- * Everything here is *framing*: what a section is called, which named pass
1760
- * bundle supplies the repository's own body rewrites, and the residue of the
1761
- * generated Hugo configuration that is genuinely this repository's own. Where
1762
- * the Hugo tree is written is not a choice: `content-build site` writes it
1763
- * under `build/hugo/`, and a `site.out` is refused by name. How a page gets
1764
- * its **address** is deliberately not here either — that is
1765
- * `publish.address`, shared with the link manifest so the two cannot disagree
1766
- * about where a page is.
1767
- *
1768
- * **What the site publishes is the content tree, and nothing beside it.** A
1769
- * page of documentation is a note — `type: doc`, addressed by its shortcode,
1770
- * compiling into no document where it says `pack: none` — so there is no
1771
- * second mechanism for mounting a directory of markdown, and a configuration
1772
- * that names one (`site.trees`, and the `site.readmeSections` that titled
1773
- * such a tree's landing) is refused with a message saying where the page
1774
- * goes instead.
1671
+ * Everything here is *framing*: which named pass bundle supplies the
1672
+ * repository's own body rewrites, and the residue of the generated Hugo
1673
+ * configuration that is genuinely this repository's own. Where the Hugo tree
1674
+ * is written is not a choice: `content-build site` writes it under
1675
+ * `build/hugo/`, and a `site.out` is refused by name. How a page gets its
1676
+ * **address** is deliberately not here either — that is `publish.address`,
1677
+ * shared with the link manifest so the two cannot disagree about where a
1678
+ * page is.
1679
+ *
1680
+ * **What the site publishes is the homepage and the content tree, and nothing
1681
+ * beside them.** A page of documentation is a note — `type: doc`, addressed
1682
+ * by its shortcode, compiling into no document where it says `pack: none` —
1683
+ * so there is no second mechanism for mounting a directory of markdown, and a
1684
+ * configuration that names one (`site.trees`, and the `site.readmeSections`
1685
+ * that titled such a tree's landing) is refused with a message saying where
1686
+ * the page goes instead. An index between the homepage and the pages is a
1687
+ * `doc` note too, so the keys that asked the build to generate one
1688
+ * ({@link SITE_INDEX_KEYS}) are refused the same way.
1775
1689
  *
1776
1690
  * @param {unknown} value - The `site` block, or `undefined`.
1777
1691
  * @returns {Readonly<object>} It, frozen, with every default filled.
@@ -1782,12 +1696,8 @@ function normalizeSite(value) {
1782
1696
  assets: "",
1783
1697
  description: "",
1784
1698
  packages: Object.freeze([]),
1785
- sections: Object.freeze({}),
1786
- landing: null,
1787
1699
  pass: "",
1788
1700
  passOptions: Object.freeze({}),
1789
- backfillSections: false,
1790
- list: Object.freeze({ shortcodes: false }),
1791
1701
  notfound: null,
1792
1702
  hugo: Object.freeze({}),
1793
1703
  });
@@ -1813,10 +1723,16 @@ function normalizeSite(value) {
1813
1723
  `site.${key}`,
1814
1724
  "is retired — a page is a note in the content tree. Give each page " +
1815
1725
  "`type: doc`, a `shortcode` and `pack: none`, file it under " +
1816
- "`assets/content/`, and declare the section that lists it under " +
1817
- "`site.sections`",
1726
+ "`assets/content/`, and link it from the homepage or from a " +
1727
+ "`doc` note that indexes it",
1818
1728
  );
1819
1729
  }
1730
+ // And by name, for the same reason: an index between the homepage and
1731
+ // the pages is a `doc` note, and the useful thing to say is that.
1732
+ for (const key of SITE_INDEX_KEYS) {
1733
+ if (input[key] === undefined) continue;
1734
+ fail(`site.${key}`, SITE_INDEX_MESSAGE);
1735
+ }
1820
1736
  rejectUnknownKeys(input, SITE_KEYS, "site.");
1821
1737
 
1822
1738
  let packages = [];
@@ -1827,30 +1743,16 @@ function normalizeSite(value) {
1827
1743
  packages = input.packages.map((p, i) => requireNonEmptyString(p, `site.packages[${i}]`));
1828
1744
  }
1829
1745
 
1830
- let landing = null;
1831
- if (input.landing !== undefined) {
1832
- if (!isPlainObject(input.landing)) {
1833
- fail("site.landing", "must be a mapping");
1834
- }
1835
- // Passed through rather than validated field by field: it is Hugo
1836
- // frontmatter, whose vocabulary is the theme's and not this package's.
1837
- landing = Object.freeze({ ...input.landing });
1838
- }
1839
-
1840
1746
  return Object.freeze({
1841
1747
  base: input.base === undefined ? "" : requireNonEmptyString(input.base, "site.base"),
1842
1748
  assets: normalizeSiteAssets(input.assets),
1843
1749
  description: normalizeSiteDescription(input.description),
1844
1750
  packages: Object.freeze(packages),
1845
- sections: normalizeSectionMap(input.sections, "site.sections"),
1846
- landing,
1847
1751
  pass: input.pass === undefined ? "" : requireNonEmptyString(input.pass, "site.pass"),
1848
1752
  passOptions:
1849
1753
  input.passOptions === undefined ?
1850
1754
  Object.freeze({})
1851
1755
  : Object.freeze({ ...input.passOptions }),
1852
- backfillSections: optionalBoolean(input.backfillSections, "site.backfillSections", false),
1853
- list: normalizeSiteList(input.list),
1854
1756
  notfound: normalizeSiteNotfound(input.notfound),
1855
1757
  hugo: normalizeSiteHugo(input.hugo),
1856
1758
  });
package/docs/api.md CHANGED
@@ -308,20 +308,19 @@ Schema fields a note may never author, because the document writes them during p
308
308
 
309
309
  The package homepage: a note that compiles to a page rather than a compendium document, hand-authored at the conventional shortcode `root`, checked for its address fields and for uniqueness across the tree.
310
310
 
311
- | Export | Signature | Returns | Use it when |
312
- | ---------------------------- | ------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------ |
313
- | `HOMEPAGE_TYPE` | `const HOMEPAGE_TYPE` | — | naming the note type that compiles to the package homepage |
314
- | `HOMEPAGE_FIELDS` | `const HOMEPAGE_FIELDS` | — | declaring what a homepage note may write under `sohl:` — nothing |
315
- | `HOMEPAGE_SHORTCODE` | `const HOMEPAGE_SHORTCODE` | — | naming the shortcode a package landing conventionally takes |
316
- | `homepageDestination` | `homepageDestination(fm)` | `string` e.g. `homepage-root.md` | naming the file a homepage is written to, relative to the site root |
317
- | `isHomepage` | `isHomepage(fm)` | `boolean` | checking whether a note's frontmatter declares the homepage type |
318
- | `HOMEPAGE_REFUSED_FIELDS` | `const HOMEPAGE_REFUSED_FIELDS` | — | naming the top-level field a homepage refuses, and what it would decide |
319
- | `checkHomepageAddressFields` | `checkHomepageAddressFields(fm, ...)` | one finding per issue | checking what the address rule says about one homepage note's top-level fields |
320
- | `checkHomepageCount` | `checkHomepageCount(found, ...)` | one finding per offending note | requiring exactly one homepage note in a content tree |
321
- | `homepageTitle` | `homepageTitle(fm, config)` | `string` | resolving the title a homepage publishes under, defaulting to the package's manifest title |
322
- | `homepageFrontmatter` | `homepageFrontmatter(fm, ...)` | `object` | assembling the frontmatter a homepage publishes with, note plus derived values |
323
- | `HOMEPAGE_ADDRESS_KEYS` | `const HOMEPAGE_ADDRESS_KEYS` | | naming the two frontmatter keys that hold an address, and what each means |
324
- | `homepageAddresses` | `homepageAddresses(fm, body, ...)` | `Array<{field, url, kind}>` | finding every address a homepage carries, in frontmatter and body both |
311
+ | Export | Signature | Returns | Use it when |
312
+ | ---------------------------- | ------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------ |
313
+ | `HOMEPAGE_TYPE` | `const HOMEPAGE_TYPE` | — | naming the note type that compiles to the package homepage |
314
+ | `HOMEPAGE_FIELDS` | `const HOMEPAGE_FIELDS` | — | declaring what a homepage note may write under `sohl:` — nothing |
315
+ | `HOMEPAGE_SHORTCODE` | `const HOMEPAGE_SHORTCODE` | — | naming the shortcode a package front page conventionally takes |
316
+ | `HOMEPAGE_DESTINATION` | `const HOMEPAGE_DESTINATION` | — | naming the file a homepage is written to, `_index.md` at the site root |
317
+ | `isHomepage` | `isHomepage(fm)` | `boolean` | checking whether a note's frontmatter declares the homepage type |
318
+ | `HOMEPAGE_REFUSED_FIELDS` | `const HOMEPAGE_REFUSED_FIELDS` | — | naming the top-level fields a homepage refuses, and what each would decide |
319
+ | `checkHomepageAddressFields` | `checkHomepageAddressFields(fm, ...)` | one finding per issue | checking what the address rule says about one homepage note's top-level fields |
320
+ | `checkHomepageCount` | `checkHomepageCount(found, ...)` | one finding per offending note | requiring exactly one homepage note in a content tree |
321
+ | `homepageTitle` | `homepageTitle(fm, config)` | `string` | resolving the title a homepage publishes under, defaulting to the package's manifest title |
322
+ | `homepageFrontmatter` | `homepageFrontmatter(fm, ...)` | `object` | assembling the frontmatter a homepage publishes with, note plus derived values |
323
+ | `homepageAddresses` | `homepageAddresses(body)` | `Array<{field, url, kind}>` | finding every address a homepage carries the markdown links in its body |
325
324
 
326
325
  ### `engine.noteSchemas`
327
326
 
@@ -345,7 +344,6 @@ The closed half of a note's frontmatter: the `data:` container and each type's `
345
344
  | `applicableTagGroups` | `applicableTagGroups(type, groups)` | `object[]` | reading the declared tag groups that apply to a note type |
346
345
  | `exclusiveTagGroups` | `exclusiveTagGroups(type, groups)` | `Array<{slot, tags}>` | reading the single-valued tag slots a note type has, such as a being's kind |
347
346
  | `hasTag` | `hasTag(fm, tag)` | `boolean` | checking whether a note carries a given tag, whatever scalar-or-list form it was authored in |
348
- | `hasAnyTag` | `hasAnyTag(fm)` | `boolean` | checking whether a note carries any `tags:` at all, for the site build's taxonomy decision |
349
347
  | `isDraftNote` | `isDraftNote(fm)` | `boolean` | checking whether a note is tagged as an unfinished draft |
350
348
  | `subTypeCharsetMessage` | `subTypeCharsetMessage(value)` | `string` | building the message for a `subType` outside the address charset |
351
349
  | `typeCharsetMessage` | `typeCharsetMessage(type)` | `string` | building the message for a `type` outside the address charset |
@@ -608,23 +606,20 @@ The toolchain's own content index — the files it ships, addressed. Every other
608
606
 
609
607
  Publishing a content tree as a website. Compiling a content tree into compendium packs is `content-build package compile`. Publishing the _same tree_ as a website was a script each consumer wrote for itself — 473 code lines in `sohl` and 462 in `sohl-thalorna`, 87 of them identical — and the copies drifted in ways neither repository could see. `sohl-thalorna` reimplemented four things this package already exported, not because it needed different behaviour but because its script predates the extraction. That is the failure a command removes: a consumer cannot accidentally reimplement one.
610
608
 
611
- | Export | Signature | Returns | Use it when |
612
- | ---------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
613
- | `collectContentPages` | `function collectContentPages(contentBase, ctx)` | {{pages: object[], addressFindings: object[], fmLinkFindings: object[]}} | The content tree's pages, and what could not be addressed. |
614
- | `collectHomepages` | `function collectHomepages(contentBase, ctx)` | {{pages: object[], addressFindings: object[]}} The homepage notes, in walk order, and the ones among them that could not be addressed. | The package's homepage notes — the authored page at `/<contentPackage>/`. |
615
- | `writeHomepages` | `function writeHomepages(outRoot, pages, config)` | {number} How many pages were written. | Writes each homepage at its address, below the package's own root. |
616
- | `siteGates` | `function siteGates(pages, findings,` | {object} The gate results and, when they pass, the built index. | The integrity gates a site build runs before it writes anything. |
617
- | `emptyGates` | `function emptyGates()` | {object} An all-clear gate result. | The gate result of a build that ran none of them. |
618
- | `gatesFailed` | `function gatesFailed(gates)` | — | Whether any gate produced a finding. |
619
- | `tableUniverse` | `function tableUniverse(pages)` | {Map<string, object[]>} Package → the notes it may tabulate. | The universe a generated table searches, grouped by package. |
620
- | `sectionFrontmatter` | `function sectionFrontmatter(meta)` | {object} Its front matter, `title` first. | The front matter a section's landing states about itself. |
621
- | `pageFrontmatter` | `function pageFrontmatter(page,` | {object} The frontmatter to write. | The frontmatter a page publishes with. |
622
- | `pageDestination` | `function pageDestination(page)` | — | Where a page is written, relative to the output root. |
623
- | `renderPages` | `function renderPages(pages, options)` | {{written: number, byKind: Record<string, number>, tableErrors: object[], wikiErrors: object[]}} | Renders and writes every page. |
624
- | `writeSectionLandings` | `function writeSectionLandings(outRoot,` | {number} How many landings were written. | Writes the Hugo sections a published tree declares. |
625
- | `pluralTitle` | `function pluralTitle(name)` | {string} The display title. | A section landing's title, from its directory name — `macro` → `Macros`. |
626
- | `resolveSitePass` | `function resolveSitePass(name, options)` | {{beforeLinks?: Function}} The bundle. | Resolves `site.pass` to its bundle. |
627
- | `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. |
609
+ | Export | Signature | Returns | Use it when |
610
+ | --------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
611
+ | `collectContentPages` | `function collectContentPages(contentBase, ctx)` | {{pages: object[], addressFindings: object[], fmLinkFindings: object[]}} | The content tree's pages, and what could not be addressed. |
612
+ | `collectHomepages` | `function collectHomepages(contentBase, ctx)` | {{pages: object[], addressFindings: object[]}} The homepage notes, in walk order, and the ones among them that could not be addressed. | The package's homepage notes — the authored page at `/<contentPackage>/`. |
613
+ | `writeHomepages` | `function writeHomepages(outRoot, pages, config)` | {number} How many pages were written. | Writes each homepage as the package root's `_index.md`. |
614
+ | `siteGates` | `function siteGates(pages, findings,` | {object} The gate results and, when they pass, the built index. | The integrity gates a site build runs before it writes anything. |
615
+ | `emptyGates` | `function emptyGates()` | {object} An all-clear gate result. | The gate result of a build that ran none of them. |
616
+ | `gatesFailed` | `function gatesFailed(gates)` | — | Whether any gate produced a finding. |
617
+ | `tableUniverse` | `function tableUniverse(pages)` | {Map<string, object[]>} Package → the notes it may tabulate. | The universe a generated table searches, grouped by package. |
618
+ | `pageFrontmatter` | `function pageFrontmatter(page,` | {object} The frontmatter to write. | The frontmatter a page publishes with. |
619
+ | `pageDestination` | `function pageDestination(page)` | — | Where a page is written, relative to the output root. |
620
+ | `renderPages` | `function renderPages(pages, options)` | {{written: number, byKind: Record<string, number>, tableErrors: object[], wikiErrors: object[]}} | Renders and writes every page. |
621
+ | `resolveSitePass` | `function resolveSitePass(name, options)` | {{beforeLinks?: Function}} The bundle. | Resolves `site.pass` to its bundle. |
622
+ | `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. |
628
623
 
629
624
  ### `engine.contentLint`
630
625