@heroiclands/package-build 22.3.1 → 22.4.1

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.
@@ -1506,18 +1506,17 @@ function pdfCommand() {
1506
1506
  * The sibling of `package compile`: the same tree, rendered as pages instead of
1507
1507
  * compiled into packs. Everything a consumer would otherwise write for itself —
1508
1508
  * the walk, the address derivation, the address index, table expansion,
1509
- * wikilink resolution, code-fence protection, the foreign-manifest merge and
1510
- * the section-landing backfill — happens here, from configuration. So does the
1511
- * Hugo configuration: the whole source tree Hugo reads lands under
1512
- * `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.
1513
1513
  *
1514
1514
  * **The Hugo configuration is generated before anything is written.** Its
1515
1515
  * sources — `package.json`'s `homepage`, the cached navigation, the installed
1516
1516
  * theme — are each a way the build can fail, and failing before the output
1517
- * tree is cleared leaves the last good site in place to be looked at. It is
1518
- * generated again once the site walk completes, because whether the site
1519
- * emits taxonomy pages is read from the walk whether any note carries
1520
- * `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.
1521
1520
  *
1522
1521
  * **Each gate is reported and the run stops at the first that fires.** They are
1523
1522
  * ordered so the report names the cause rather than its symptoms: an unusable
@@ -1535,14 +1534,12 @@ function siteCommand() {
1535
1534
  handler: async () => {
1536
1535
  try {
1537
1536
  const config = loadPackConfig();
1538
- // Generated once before the walk, purely to fail fast on a
1539
- // missing or mismatched source — `homepage`, the manifest
1540
- // title, the cached navigation, the installed theme — while
1541
- // the last good site is still in place to be looked at. Its
1542
- // `disableKinds`/`taxonomies`/`outputs` are provisional: only
1543
- // the site walk below knows whether any note carries `tags:`,
1544
- // so the value actually written is regenerated after it runs.
1545
- 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);
1546
1543
  const result = buildSite({
1547
1544
  config,
1548
1545
  sqlTables: await prepareTreeSqlTables(config.paths.content, {
@@ -1653,11 +1650,9 @@ function siteCommand() {
1653
1650
  const s = result.stats;
1654
1651
  log.info(
1655
1652
  `wrote ${s.homepages ?? 0} homepage(s) + ` +
1656
- `${s.content ?? 0} content page(s) + ` +
1657
- `${s.tree ?? 0} tree page(s) + ${s.landings} ` +
1658
- `landing(s) to ${path.relative(process.cwd(), s.out)}`,
1653
+ `${s.content ?? 0} content page(s) to ` +
1654
+ `${path.relative(process.cwd(), s.out)}`,
1659
1655
  );
1660
- const hugo = generateHugoConfig(config, { hasTags: result.hasTags });
1661
1656
  const { file } = writeHugoConfig(config, hugo);
1662
1657
  log.info(`wrote ${path.relative(process.cwd(), file)}`);
1663
1658
  } catch (err) {
@@ -53,6 +53,7 @@
53
53
  * npx package-build lang check
54
54
  * npx package-build lang coverage [--unused]
55
55
  * npx package-build lang hardcoded
56
+ * npx package-build changelog check [--release] [paths..]
56
57
  * npx package-build bundle check
57
58
  * npx package-build release
58
59
  * npx package-build deploy <stage>
@@ -88,6 +89,7 @@ import { SCHEMA_ARTIFACT_FILE } from "../engine/foreign-catalog.mjs";
88
89
  import { validateLangSource } from "../lang.mjs";
89
90
  import { checkLabelRegistry } from "../labels.mjs";
90
91
  import { lintYaml } from "../engine/yaml-lint.mjs";
92
+ import { lintChangesetText, lintReleaseText } from "../engine/changelog-lint.mjs";
91
93
  import { bumpDependencies } from "../engine/dependency-bump.mjs";
92
94
  import {
93
95
  analyzeCoverage,
@@ -455,15 +457,16 @@ function manifestCommand() {
455
457
  * `package-build site-root` — the deployment's root files.
456
458
  *
457
459
  * 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.
460
+ * beside it, which is what Cloudflare Pages reads from the uploaded directory
461
+ * and nowhere else: `_headers`, and no `_redirects`, since the prefix root is
462
+ * the homepage.
460
463
  *
461
464
  * @returns {object} The yargs command.
462
465
  */
463
466
  function siteRootCommand() {
464
467
  return {
465
468
  command: "site-root",
466
- describe: "Write the deployment's _headers and _redirects",
469
+ describe: "Write the deployment's _headers",
467
470
  builder: (y) =>
468
471
  y.option("out", {
469
472
  type: "string",
@@ -775,6 +778,86 @@ function yamlCommand() {
775
778
  };
776
779
  }
777
780
 
781
+ /**
782
+ * `changelog check` — lint release prose against the rules a changeset is
783
+ * actually held to (`check` is the only action).
784
+ *
785
+ * A changeset answers one question — who notices, and what do they see — and
786
+ * nothing enforced it, so a pull-request description pasted into one ships
787
+ * verbatim as a release note. Default reads every pending changeset;
788
+ * `--release` reads the first `## <version>` section of `CHANGELOG.md`
789
+ * instead, for the **Version Packages** branch a merge to `main` opens.
790
+ *
791
+ * @returns {object} The yargs command module.
792
+ */
793
+ function changelogCommand() {
794
+ return {
795
+ command: "changelog <action> [paths..]",
796
+ describe: "Release-prose checks",
797
+ builder: (y) =>
798
+ y
799
+ .positional("action", {
800
+ choices: ["check"],
801
+ describe: "check: lint pending changesets, or a release section",
802
+ })
803
+ .positional("paths", {
804
+ describe:
805
+ "Files to check. Defaults to `.changeset/*.md` (config.json and " +
806
+ "README.md excluded), or `CHANGELOG.md` with --release.",
807
+ type: "string",
808
+ })
809
+ .option("release", {
810
+ type: "boolean",
811
+ default: false,
812
+ describe:
813
+ "Check the first `## <version>` section of CHANGELOG.md instead of " +
814
+ "pending changesets",
815
+ }),
816
+ handler: handler(async (args) => changelogCheck(args)),
817
+ };
818
+ }
819
+
820
+ /**
821
+ * The files `changelog check` reads by default: every pending changeset, or
822
+ * `CHANGELOG.md` alone under `--release`.
823
+ *
824
+ * `config.json` is excluded by the glob itself (it is not `.md`); `README.md`
825
+ * is excluded by name, since it is prose about changesets rather than one.
826
+ *
827
+ * @param {object} args - Parsed CLI arguments.
828
+ * @returns {string[]} Paths, relative to the working directory.
829
+ */
830
+ function changelogFiles(args) {
831
+ if (args.paths?.length) return args.paths;
832
+ if (args.release) return ["CHANGELOG.md"];
833
+ return globSync(".changeset/*.md", { cwd: process.cwd() }).filter(
834
+ (file) => path.basename(file) !== "README.md",
835
+ );
836
+ }
837
+
838
+ /**
839
+ * Run `changelog check` over every resolved file and report the result.
840
+ *
841
+ * @param {object} args - Parsed CLI arguments.
842
+ */
843
+ function changelogCheck(args) {
844
+ const files = changelogFiles(args);
845
+ let errors = 0;
846
+ let total = 0;
847
+ for (const file of files) {
848
+ if (!fs.existsSync(file)) die(`changelog check: ${file} does not exist.`);
849
+ const text = fs.readFileSync(file, "utf8");
850
+ const { findings } = args.release ? lintReleaseText(text) : lintChangesetText(text);
851
+ errors += reportFindings(findings, { file });
852
+ total += findings.length;
853
+ }
854
+ console.log(
855
+ `package-build: ${files.length} file(s) checked · ` +
856
+ `${errors} error(s) · ${total - errors} warning(s)`,
857
+ );
858
+ if (errors) process.exitCode = 1;
859
+ }
860
+
778
861
  /**
779
862
  * `labels check` — do the machine registry and the documented table agree?
780
863
  *
@@ -1236,6 +1319,7 @@ yargs(hideBin(process.argv))
1236
1319
  .command(labelsCommand())
1237
1320
  .command(bumpCommand())
1238
1321
  .command(yamlCommand())
1322
+ .command(changelogCommand())
1239
1323
  .command(bundleCommand())
1240
1324
  .command(releaseCommand())
1241
1325
  .command(deployCommand())
@@ -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'
@@ -766,22 +765,16 @@ const SITE_KEYS = [
766
765
  "assets",
767
766
  "description",
768
767
  "packages",
769
- "sections",
770
- "landing",
771
768
  "pass",
772
769
  "passOptions",
773
- "backfillSections",
774
- "list",
775
770
  "notfound",
776
771
  "hugo",
777
772
  ];
778
- const SITE_LIST_KEYS = ["shortcodes"];
779
773
  const SITE_NOTFOUND_KEYS = ["tagline", "sitenoun", "heroimage", "links"];
780
774
  const SITE_NOTFOUND_LINK_KEYS = ["title", "url", "text"];
781
775
  const PDF_KEYS = ["title", "subtitle", "document", "out", "front", "fonts", "iconFonts", "binary"];
782
776
  const PDF_FONT_KEYS = ["serif", "sans", "mono", "path"];
783
777
  const EMPTY_PDF_FONTS = Object.freeze({ serif: "", sans: "", mono: "", path: "" });
784
- const SECTION_META_KEYS = ["title", "banner", "description", "listType", "listSubType"];
785
778
  const DOC_PAGE_KEYS = ["title", "out", "preamble", "frontmatter"];
786
779
  const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
787
780
  const RELATIONSHIP_KEYS = [
@@ -1465,102 +1458,6 @@ function normalizeDocs(value) {
1465
1458
  });
1466
1459
  }
1467
1460
 
1468
- /**
1469
- * One section's landing metadata — what a section says about itself on the
1470
- * `_index.md` this build generates for it.
1471
- *
1472
- * A generated landing is the *only* place a section can speak, and it
1473
- * is the only place a section **exists**: a content page is addressed
1474
- * `(type, shortcode)` and written flat under the mount, so no page creates a
1475
- * directory and nothing else makes `<prefix><section>/` answer. This is
1476
- * therefore the whole vocabulary, and it is deliberately a **closed** one.
1477
- *
1478
- * The alternative — passing whatever a section declared straight through, as
1479
- * `site.landing` does — was weighed and refused. `landing` is written once, for
1480
- * the mount, and its keys are one landing template's own; a section entry is
1481
- * written fourteen to twenty times per build against a contract every package
1482
- * and every section shares. Unbounded there, a mistyped `descrption:` publishes
1483
- * into front matter, is read by nobody, and says nothing to anyone — which is
1484
- * the same failure, moved one step downstream where no build can
1485
- * see it. So the keys are named here, and the writers emit what this produced
1486
- * rather than transcribing a second list of their own.
1487
- *
1488
- * `banner` and `description` are optional — the hero images are external assets
1489
- * and not every section has one, and a section may reasonably have nothing to
1490
- * add to its title. Each is left off entirely rather than written as
1491
- * `undefined`, which is not a value YAML can carry.
1492
- *
1493
- * **`listType` / `listSubType` say what the section lists.** A section's
1494
- * directory holds nothing but the `_index.md` written here, so a layout
1495
- * reading Hugo's `.Pages` finds
1496
- * no members and renders an empty landing. The membership survives in this map
1497
- * and nowhere a theme can reach it, so the landing states it and a layout
1498
- * substitutes the equivalent `site.RegularPages` query — the same one `sohl`'s
1499
- * catalog layouts already run, which is why `sohl`'s landings never broke.
1500
- *
1501
- * They are two keys of their own rather than `type` / `subType` because `type`
1502
- * on an `_index.md` is **Hugo's own layout selector**: verified against Hugo
1503
- * 0.165, a section landing carrying `type: doc` renders through
1504
- * `layouts/doc/list.html` rather than the default list template, so spelling
1505
- * the content type there would silently change which template serves the
1506
- * landing. (This build already uses that behaviour deliberately, for the
1507
- * mount's own landing.)
1508
- *
1509
- * Both are checked as **address segments**, which is the trap this came from:
1510
- * a section is named for the URL a consumer chose and a subType is an address
1511
- * segment, and the two need not agree — `/sohl/kb/user-guide/` is the section,
1512
- * `userguide` the subType. Copying the section's name into the
1513
- * declaration would select no page at all, and an empty landing reported by
1514
- * nobody is the failure being fixed. A `listSubType` with no `listType` is
1515
- * refused for the same reason: a subType is only distinguishing *within* a
1516
- * type — `rules`, `userguide` and `reference` are all `doc` — so alone it names
1517
- * no query.
1518
- *
1519
- * @param {unknown} value - The declared entry.
1520
- * @param {string} where - Dotted path, for the error.
1521
- * @returns {Readonly<{title: string, banner?: string, description?: string,
1522
- * listType?: string, listSubType?: string}>}
1523
- */
1524
- function normalizeSectionMeta(value, where) {
1525
- if (!isPlainObject(value)) fail(where, "must be a mapping");
1526
- const input = /** @type {Record<string, unknown>} */ (value);
1527
- rejectUnknownKeys(input, SECTION_META_KEYS, `${where}.`);
1528
- const out = { title: requireNonEmptyString(input.title, `${where}.title`) };
1529
- if (input.banner !== undefined) {
1530
- out.banner = requireNonEmptyString(input.banner, `${where}.banner`);
1531
- }
1532
- if (input.description !== undefined) {
1533
- out.description = requireNonEmptyString(input.description, `${where}.description`);
1534
- }
1535
- for (const key of ["listType", "listSubType"]) {
1536
- if (input[key] === undefined) continue;
1537
- const segment = requireNonEmptyString(input[key], `${where}.${key}`);
1538
- if (!isAddressSegment(segment)) {
1539
- fail(
1540
- `${where}.${key}`,
1541
- `is \`${segment}\`, which is not lowercase alphanumeric. It names a ` +
1542
- "content type or subType, and those are address segments " +
1543
- `(${ADDRESS_SEGMENT_PATTERN.source}) — not the section's ` +
1544
- "own name, which is a URL this site chose and need not " +
1545
- "match (`user-guide` is the section, `userguide` the " +
1546
- "subType). A value no page carries selects nothing and " +
1547
- "leaves the landing empty",
1548
- );
1549
- }
1550
- out[key] = segment;
1551
- }
1552
- if (out.listSubType !== undefined && out.listType === undefined) {
1553
- fail(
1554
- `${where}.listSubType`,
1555
- "is declared without a `listType`. A subType tells pages apart " +
1556
- "only within a type — `rules`, `userguide` and `reference` " +
1557
- "are all `doc` — so on its own it names no query for a layout " +
1558
- "to run",
1559
- );
1560
- }
1561
- return Object.freeze(out);
1562
- }
1563
-
1564
1461
  /**
1565
1462
  * The asset host the website resolves a pathname against.
1566
1463
  *
@@ -1620,23 +1517,6 @@ function normalizeSiteDescription(value) {
1620
1517
  return description;
1621
1518
  }
1622
1519
 
1623
- /**
1624
- * A map of section name → landing metadata.
1625
- *
1626
- * @param {unknown} value - The declared mapping.
1627
- * @param {string} where - Dotted path, for the error.
1628
- * @returns {Readonly<Record<string, object>>}
1629
- */
1630
- function normalizeSectionMap(value, where) {
1631
- if (value === undefined) return Object.freeze({});
1632
- if (!isPlainObject(value)) fail(where, "must be a mapping");
1633
- const out = {};
1634
- for (const [name, meta] of Object.entries(/** @type {Record<string, unknown>} */ (value))) {
1635
- out[name] = normalizeSectionMeta(meta, `${where}.${name}`);
1636
- }
1637
- return Object.freeze(out);
1638
- }
1639
-
1640
1520
  /**
1641
1521
  * Hugo keys a repository may **not** declare under `site.hugo`, because the
1642
1522
  * site build generates them and would only overwrite what was written.
@@ -1658,35 +1538,24 @@ export const DERIVED_HUGO_KEYS = Object.freeze({
1658
1538
  contentDir: "the fixed content mount, `build/hugo/content`",
1659
1539
  themesDir: "where `@heroiclands/hugo-theme` is installed",
1660
1540
  theme: "the installed `@heroiclands/hugo-theme`",
1661
- disableKinds: "whether any note in the tree carries `tags:`, which the site walk discovers",
1662
- taxonomies: "whether any note in the tree carries `tags:`, which the site walk discovers",
1663
- 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",
1664
1550
  "params.description": "`site.description`",
1665
1551
  "params.author": "package.json `author`",
1666
1552
  "params.cdnBaseURL": "`site.assets`",
1667
1553
  "params.brand": "the organisation's brand links, in `engine/site-config.mjs`",
1668
- "params.list": "`site.list`",
1669
1554
  "params.notfound": "`site.notfound`",
1670
1555
  "markup.goldmark.renderer.unsafe": "the toolchain, whose pages carry raw HTML",
1671
1556
  menu: "the navigation `content-build deps fetch` caches from heroiclands.org",
1672
1557
  });
1673
1558
 
1674
- /**
1675
- * The `site.list` block — how a listing page renders.
1676
- *
1677
- * @param {unknown} value - The block, or `undefined`.
1678
- * @returns {Readonly<{shortcodes: boolean}>} It, frozen, with every default filled.
1679
- */
1680
- function normalizeSiteList(value) {
1681
- if (value === undefined) return Object.freeze({ shortcodes: false });
1682
- if (!isPlainObject(value)) fail("site.list", "must be a mapping");
1683
- const input = /** @type {Record<string, unknown>} */ (value);
1684
- rejectUnknownKeys(input, SITE_LIST_KEYS, "site.list.");
1685
- return Object.freeze({
1686
- shortcodes: optionalBoolean(input.shortcodes, "site.list.shortcodes", false),
1687
- });
1688
- }
1689
-
1690
1559
  /**
1691
1560
  * The `site.notfound` block — the wording of the "page not found" page.
1692
1561
  *
@@ -1764,25 +1633,59 @@ function normalizeSiteHugo(value) {
1764
1633
  return Object.freeze(structuredClone(input));
1765
1634
  }
1766
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
+
1767
1668
  /**
1768
1669
  * The `site` section — how this repository frames the website it publishes.
1769
1670
  *
1770
- * Everything here is *framing*: what a section is called, which named pass
1771
- * bundle supplies the repository's own body rewrites, and the residue of the
1772
- * generated Hugo configuration that is genuinely this repository's own. Where
1773
- * the Hugo tree is written is not a choice: `content-build site` writes it
1774
- * under `build/hugo/`, and a `site.out` is refused by name. How a page gets
1775
- * its **address** is deliberately not here either — that is
1776
- * `publish.address`, shared with the link manifest so the two cannot disagree
1777
- * about where a page is.
1778
- *
1779
- * **What the site publishes is the content tree, and nothing beside it.** A
1780
- * page of documentation is a note — `type: doc`, addressed by its shortcode,
1781
- * compiling into no document where it says `pack: none` — so there is no
1782
- * second mechanism for mounting a directory of markdown, and a configuration
1783
- * that names one (`site.trees`, and the `site.readmeSections` that titled
1784
- * such a tree's landing) is refused with a message saying where the page
1785
- * 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.
1786
1689
  *
1787
1690
  * @param {unknown} value - The `site` block, or `undefined`.
1788
1691
  * @returns {Readonly<object>} It, frozen, with every default filled.
@@ -1793,12 +1696,8 @@ function normalizeSite(value) {
1793
1696
  assets: "",
1794
1697
  description: "",
1795
1698
  packages: Object.freeze([]),
1796
- sections: Object.freeze({}),
1797
- landing: null,
1798
1699
  pass: "",
1799
1700
  passOptions: Object.freeze({}),
1800
- backfillSections: false,
1801
- list: Object.freeze({ shortcodes: false }),
1802
1701
  notfound: null,
1803
1702
  hugo: Object.freeze({}),
1804
1703
  });
@@ -1824,10 +1723,16 @@ function normalizeSite(value) {
1824
1723
  `site.${key}`,
1825
1724
  "is retired — a page is a note in the content tree. Give each page " +
1826
1725
  "`type: doc`, a `shortcode` and `pack: none`, file it under " +
1827
- "`assets/content/`, and declare the section that lists it under " +
1828
- "`site.sections`",
1726
+ "`assets/content/`, and link it from the homepage or from a " +
1727
+ "`doc` note that indexes it",
1829
1728
  );
1830
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
+ }
1831
1736
  rejectUnknownKeys(input, SITE_KEYS, "site.");
1832
1737
 
1833
1738
  let packages = [];
@@ -1838,30 +1743,16 @@ function normalizeSite(value) {
1838
1743
  packages = input.packages.map((p, i) => requireNonEmptyString(p, `site.packages[${i}]`));
1839
1744
  }
1840
1745
 
1841
- let landing = null;
1842
- if (input.landing !== undefined) {
1843
- if (!isPlainObject(input.landing)) {
1844
- fail("site.landing", "must be a mapping");
1845
- }
1846
- // Passed through rather than validated field by field: it is Hugo
1847
- // frontmatter, whose vocabulary is the theme's and not this package's.
1848
- landing = Object.freeze({ ...input.landing });
1849
- }
1850
-
1851
1746
  return Object.freeze({
1852
1747
  base: input.base === undefined ? "" : requireNonEmptyString(input.base, "site.base"),
1853
1748
  assets: normalizeSiteAssets(input.assets),
1854
1749
  description: normalizeSiteDescription(input.description),
1855
1750
  packages: Object.freeze(packages),
1856
- sections: normalizeSectionMap(input.sections, "site.sections"),
1857
- landing,
1858
1751
  pass: input.pass === undefined ? "" : requireNonEmptyString(input.pass, "site.pass"),
1859
1752
  passOptions:
1860
1753
  input.passOptions === undefined ?
1861
1754
  Object.freeze({})
1862
1755
  : Object.freeze({ ...input.passOptions }),
1863
- backfillSections: optionalBoolean(input.backfillSections, "site.backfillSections", false),
1864
- list: normalizeSiteList(input.list),
1865
1756
  notfound: normalizeSiteNotfound(input.notfound),
1866
1757
  hugo: normalizeSiteHugo(input.hugo),
1867
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