@heroiclands/package-build 20.3.0 → 20.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.
Files changed (178) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/CONTENT.md +111 -92
  3. package/README.md +2 -9
  4. package/bin/content-build.mjs +70 -61
  5. package/bin/package-build.mjs +2 -2
  6. package/config.mjs +6 -8
  7. package/content-config.mjs +187 -64
  8. package/docs/content-format.md +57 -68
  9. package/e2e.mjs +4 -4
  10. package/engine/actor-compiler.mjs +36 -36
  11. package/engine/address-charset.mjs +6 -6
  12. package/engine/address-diff.mjs +12 -11
  13. package/engine/anchored-sections.mjs +1 -1
  14. package/engine/anchors.mjs +1 -1
  15. package/engine/base-compiler.mjs +36 -38
  16. package/engine/bundle-notes.mjs +4 -4
  17. package/engine/bundles.mjs +8 -8
  18. package/engine/code-fences.mjs +3 -3
  19. package/engine/compendiums.mjs +8 -8
  20. package/engine/compile-corpus.mjs +4 -5
  21. package/engine/content-address.mjs +15 -15
  22. package/engine/content-charset.mjs +1 -1
  23. package/engine/content-format-check.mjs +24 -25
  24. package/engine/content-format.mjs +8 -8
  25. package/engine/content-html.mjs +202 -0
  26. package/engine/content-icons.mjs +225 -98
  27. package/engine/content-index.mjs +17 -17
  28. package/engine/content-links.mjs +30 -30
  29. package/engine/content-lint.mjs +20 -24
  30. package/engine/content-package.mjs +7 -9
  31. package/engine/content-slug.mjs +2 -2
  32. package/engine/content-tables.mjs +7 -8
  33. package/engine/content-tree.mjs +1 -1
  34. package/engine/derived-fields.mjs +174 -0
  35. package/engine/diagnostics.mjs +3 -4
  36. package/engine/document-subtypes.mjs +6 -7
  37. package/engine/field-reference.mjs +8 -8
  38. package/engine/field-spec.mjs +29 -30
  39. package/engine/folder-notes.mjs +14 -14
  40. package/engine/foreign-catalog.mjs +8 -9
  41. package/engine/foundry-entries.mjs +19 -20
  42. package/engine/frontmatter-lint.mjs +64 -69
  43. package/engine/frontmatter.mjs +11 -11
  44. package/engine/generate.mjs +39 -39
  45. package/engine/helpers.mjs +63 -54
  46. package/engine/homepage.mjs +20 -22
  47. package/engine/ids.mjs +15 -15
  48. package/engine/index-records.mjs +4 -4
  49. package/engine/index.mjs +14 -8
  50. package/engine/item-compiler.mjs +54 -29
  51. package/engine/item-docs.mjs +11 -13
  52. package/engine/item-registry.mjs +13 -13
  53. package/engine/journals.mjs +15 -15
  54. package/engine/macros.mjs +3 -3
  55. package/engine/map-notes.mjs +6 -6
  56. package/engine/metadata-index.mjs +5 -5
  57. package/engine/note-claims.mjs +36 -34
  58. package/engine/note-ids.mjs +5 -5
  59. package/engine/note-package.mjs +4 -4
  60. package/engine/note-renames.mjs +3 -3
  61. package/engine/note-schemas.mjs +3 -3
  62. package/engine/note-vocabulary.mjs +27 -26
  63. package/engine/pack-config.mjs +13 -13
  64. package/engine/pack-router.mjs +0 -0
  65. package/engine/prose-config.mjs +9 -9
  66. package/engine/prose-lint.mjs +12 -13
  67. package/engine/region-events.mjs +2 -2
  68. package/engine/retired-fields.mjs +24 -22
  69. package/engine/runtime-only-fields.mjs +1 -1
  70. package/engine/scene-levels.mjs +1 -1
  71. package/engine/scenes.mjs +19 -20
  72. package/engine/schema-check.mjs +20 -20
  73. package/engine/schema-extract.mjs +1 -1
  74. package/engine/site-build.mjs +45 -47
  75. package/engine/site-index.mjs +17 -18
  76. package/engine/sql-tables.mjs +21 -13
  77. package/engine/subtype-registry.mjs +5 -4
  78. package/engine/system-block.mjs +18 -19
  79. package/engine/systems.mjs +2 -2
  80. package/engine/web-wikilinks.mjs +35 -35
  81. package/engine/wikilink-syntax.mjs +16 -16
  82. package/engine/wikilinks.mjs +39 -39
  83. package/hm3/actors.mjs +22 -22
  84. package/hm3/document-subtypes.mjs +5 -5
  85. package/hm3/index.mjs +2 -2
  86. package/hm3/item-builders.mjs +1 -1
  87. package/hm3/item-fields.mjs +3 -3
  88. package/hm3/items.mjs +20 -8
  89. package/hm3/template-priority.mjs +2 -2
  90. package/lang.mjs +3 -3
  91. package/manifest.mjs +17 -20
  92. package/package.json +1 -2
  93. package/release.mjs +3 -3
  94. package/sohl/actors.mjs +28 -28
  95. package/sohl/affiliation-standings.mjs +2 -2
  96. package/sohl/being-info.mjs +5 -5
  97. package/sohl/default-item-art.mjs +5 -5
  98. package/sohl/document-subtypes.mjs +5 -5
  99. package/sohl/index.mjs +3 -3
  100. package/sohl/item-builders.mjs +7 -7
  101. package/sohl/item-fields.mjs +8 -9
  102. package/sohl/items.mjs +20 -6
  103. package/sohl/kb-passes.mjs +5 -5
  104. package/sohl/note-schemas.mjs +7 -7
  105. package/sohl/skill-base.mjs +1 -1
  106. package/types/content-config.d.mts +25 -24
  107. package/types/e2e.d.mts +3 -3
  108. package/types/engine/actor-compiler.d.mts +14 -13
  109. package/types/engine/address-charset.d.mts +6 -6
  110. package/types/engine/address-diff.d.mts +3 -3
  111. package/types/engine/base-compiler.d.mts +17 -19
  112. package/types/engine/bundle-notes.d.mts +3 -3
  113. package/types/engine/bundles.d.mts +1 -1
  114. package/types/engine/code-fences.d.mts +3 -3
  115. package/types/engine/compendiums.d.mts +3 -3
  116. package/types/engine/compile-corpus.d.mts +1 -1
  117. package/types/engine/content-address.d.mts +11 -11
  118. package/types/engine/content-format-check.d.mts +6 -6
  119. package/types/engine/content-format.d.mts +2 -2
  120. package/types/engine/content-html.d.mts +78 -0
  121. package/types/engine/content-icons.d.mts +150 -62
  122. package/types/engine/content-index.d.mts +2 -2
  123. package/types/engine/content-links.d.mts +8 -8
  124. package/types/engine/content-lint.d.mts +2 -2
  125. package/types/engine/content-package.d.mts +6 -8
  126. package/types/engine/derived-fields.d.mts +101 -0
  127. package/types/engine/diagnostics.d.mts +2 -2
  128. package/types/engine/document-subtypes.d.mts +3 -3
  129. package/types/engine/field-spec.d.mts +37 -39
  130. package/types/engine/folder-notes.d.mts +4 -5
  131. package/types/engine/foreign-catalog.d.mts +3 -3
  132. package/types/engine/foundry-entries.d.mts +6 -7
  133. package/types/engine/frontmatter-lint.d.mts +7 -7
  134. package/types/engine/frontmatter.d.mts +7 -7
  135. package/types/engine/generate.d.mts +7 -7
  136. package/types/engine/helpers.d.mts +25 -27
  137. package/types/engine/homepage.d.mts +16 -18
  138. package/types/engine/ids.d.mts +13 -13
  139. package/types/engine/index-records.d.mts +3 -3
  140. package/types/engine/index.d.mts +2 -0
  141. package/types/engine/item-compiler.d.mts +21 -5
  142. package/types/engine/item-docs.d.mts +2 -2
  143. package/types/engine/item-registry.d.mts +6 -6
  144. package/types/engine/journals.d.mts +3 -3
  145. package/types/engine/map-notes.d.mts +2 -2
  146. package/types/engine/metadata-index.d.mts +4 -4
  147. package/types/engine/note-claims.d.mts +16 -15
  148. package/types/engine/note-ids.d.mts +4 -4
  149. package/types/engine/note-package.d.mts +1 -1
  150. package/types/engine/note-renames.d.mts +3 -3
  151. package/types/engine/note-vocabulary.d.mts +9 -8
  152. package/types/engine/pack-config.d.mts +3 -3
  153. package/types/engine/pack-router.d.mts +1 -1
  154. package/types/engine/prose-config.d.mts +9 -9
  155. package/types/engine/prose-lint.d.mts +3 -4
  156. package/types/engine/region-events.d.mts +2 -2
  157. package/types/engine/retired-fields.d.mts +10 -9
  158. package/types/engine/schema-check.d.mts +11 -11
  159. package/types/engine/site-build.d.mts +18 -19
  160. package/types/engine/site-index.d.mts +3 -4
  161. package/types/engine/sql-tables.d.mts +11 -5
  162. package/types/engine/subtype-registry.d.mts +3 -3
  163. package/types/engine/system-block.d.mts +3 -3
  164. package/types/engine/web-wikilinks.d.mts +7 -7
  165. package/types/engine/wikilink-syntax.d.mts +17 -17
  166. package/types/engine/wikilinks.d.mts +13 -12
  167. package/types/hm3/actors.d.mts +1 -1
  168. package/types/hm3/document-subtypes.d.mts +1 -1
  169. package/types/hm3/items.d.mts +1 -2
  170. package/types/hm3/template-priority.d.mts +1 -1
  171. package/types/manifest.d.mts +8 -8
  172. package/types/sohl/actors.d.mts +2 -2
  173. package/types/sohl/affiliation-standings.d.mts +2 -2
  174. package/types/sohl/being-info.d.mts +2 -2
  175. package/types/sohl/document-subtypes.d.mts +1 -1
  176. package/types/sohl/items.d.mts +1 -2
  177. package/types/sohl/note-schemas.d.mts +1 -1
  178. package/MIGRATING.md +0 -608
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Publishing a content tree as a website (#63).
15
+ * Publishing a content tree as a website.
16
16
  *
17
17
  * Compiling a content tree into compendium packs is `content-build package
18
18
  * compile`. Publishing the *same tree* as a website was a script each consumer
@@ -60,7 +60,7 @@ import { formatUnaddressableFinding, unaddressableForeignPackages } from "./meta
60
60
  import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
61
61
  import { loadPackConfig } from "./pack-config.mjs";
62
62
  import { searchableFrontmatter } from "./note-package.mjs";
63
- // The corpus, from the one pass that derives it (#243).
63
+ // The corpus, from the one pass that derives it.
64
64
  import { indexRecordsFor } from "./content-index.mjs";
65
65
  import { isNoteRecord, noteFile } from "./index-records.mjs";
66
66
  import {
@@ -81,7 +81,7 @@ const require = createRequire(import.meta.url);
81
81
  * tree in reverse. Order was load-bearing here when the address index carried
82
82
  * first-writer-wins fallbacks for a page's name, filename and slug — reversing
83
83
  * the walk silently changed which page an ambiguous name resolved to. Those
84
- * fallbacks are gone with the bare `[[Name]]` form (#180), so this is now
84
+ * fallbacks are gone with the bare `[[Name]]` form, so this is now
85
85
  * ordinary reading order rather than a dependency; it is kept because a site's
86
86
  * emitted pages should not reorder for no reason.
87
87
  *
@@ -108,7 +108,7 @@ export function walkSiteTree(dir, skip = []) {
108
108
  /**
109
109
  * The content-tree files this build publishes from, in the order it emits them.
110
110
  *
111
- * **The corpus comes from the content index** (#243) — the same derivation the
111
+ * **The corpus comes from the content index** — the same derivation the
112
112
  * packs are compiled from — so the site and the packs cannot disagree about
113
113
  * which files are the content. The note is still read for its `{fm, body}`: the
114
114
  * index carries no note text, and a page *is* its text.
@@ -118,7 +118,7 @@ export function walkSiteTree(dir, skip = []) {
118
118
  * "a site's emitted pages should not reorder for no reason". Records are in
119
119
  * content-path order, which is the same set in a different sequence. Nothing
120
120
  * downstream depends on it any more: the first-writer-wins fallbacks that made
121
- * order load-bearing went with the bare `[[Name]]` form (#180), each page is
121
+ * order load-bearing went with the bare `[[Name]]` form, each page is
122
122
  * emitted to its own file at an address derived from its frontmatter, and the
123
123
  * one place order could still show — a section's page list — is sorted by the
124
124
  * theme. Verified rather than argued: over `sohl`'s tree the emitted mount is
@@ -182,7 +182,7 @@ export function collectContentPages(contentBase, ctx) {
182
182
  // Where an addressed page publishes. A missing `base` would put *every*
183
183
  // page at `undefined/` rather than one, and it is the caller's contract
184
184
  // rather than a note's defect, so it throws instead of being collected as a
185
- // finding (#195).
185
+ // finding.
186
186
  if (typeof ctx.base !== "string" || !ctx.base) {
187
187
  throw new TypeError(
188
188
  "collectContentPages: `ctx.base` must be a non-empty string — it is the package address every page's URL is built on",
@@ -197,14 +197,13 @@ export function collectContentPages(contentBase, ctx) {
197
197
  if (!note) continue;
198
198
  const { fm, body } = note;
199
199
  // The configuration's, never a note's: `package:` is retired, so every
200
- // note in the tree belongs to the package this repository compiles
201
- // (#56).
200
+ // note in the tree belongs to the package this repository compiles.
202
201
  const pkg = ctx.contentPackage;
203
202
  if (!ctx.packages.has(pkg) || !fm.type) continue;
204
- // A homepage is addressed like any other note (#182), but it is
203
+ // A homepage is addressed like any other note, but it is
205
204
  // gathered by {@link collectHomepages} rather than here: it is the
206
205
  // whole of a homepage-only build, which never walks the tree for
207
- // content pages at all (#55).
206
+ // content pages at all.
208
207
  if (isHomepage(fm)) continue;
209
208
 
210
209
  for (const hit of frontmatterWikilinks(fm)) {
@@ -212,7 +211,7 @@ export function collectContentPages(contentBase, ctx) {
212
211
  }
213
212
 
214
213
  const name = fm.name?.full ?? path.basename(file, ".md");
215
- // The page's address, and therefore its URL (#181). `name` is the
214
+ // The page's address, and therefore its URL. `name` is the
216
215
  // display string and nothing else: it titles the page and labels an
217
216
  // inbound link, and moving it moves no address.
218
217
  let slug;
@@ -230,11 +229,11 @@ export function collectContentPages(contentBase, ctx) {
230
229
  fm,
231
230
  // The note's own path on disk. Carried so a link finding can be
232
231
  // reported as `file:line:column:` against the source an author
233
- // edits, rather than against the page this build emits (#184).
232
+ // edits, rather than against the page this build emits.
234
233
  file,
235
234
  // The page's package, recorded once here so every consumer — the
236
235
  // index's canonical keys, the table universe, the local-package set
237
- // — reads one configured value and never frontmatter (#56).
236
+ // — reads one configured value and never frontmatter.
238
237
  pkg,
239
238
  body,
240
239
  name,
@@ -251,7 +250,7 @@ export function collectContentPages(contentBase, ctx) {
251
250
  // authoring folder, for grouped landings.
252
251
  folder: path.basename(path.dirname(file)),
253
252
  // Every page is addressed by `(type, shortcode)` at the package
254
- // root, which takes no content mount (#181). The file is written
253
+ // root, which takes no content mount. The file is written
255
254
  // flat under the mount — see {@link pageDestination} — and the
256
255
  // front matter carries this `url` so Hugo publishes it at its
257
256
  // address rather than at its path.
@@ -299,7 +298,7 @@ export function collectTreePages(tree, ctx) {
299
298
  kind: "tree",
300
299
  tree,
301
300
  fm,
302
- // As above: the source file, for a located link diagnostic (#184).
301
+ // As above: the source file, for a located link diagnostic.
303
302
  file,
304
303
  // The H1 is stripped: the page title renders it.
305
304
  body: body.replace(/^\s*#\s+.*$\r?\n?/m, ""),
@@ -327,13 +326,13 @@ export function collectTreePages(tree, ctx) {
327
326
  * it, because in homepage-only mode it is the **whole** of the site build: the
328
327
  * content tree is never read for pages at all, so the licensing constraint two
329
328
  * packages ship under is a property of the code path rather than of a
330
- * configuration that happens to be empty (#55).
329
+ * configuration that happens to be empty.
331
330
  *
332
331
  * Returned as a list rather than as the one note there should be, because the
333
- * count is what {@link checkHomepageCount} judges (#52) — this walk reports
332
+ * count is what {@link checkHomepageCount} judges — this walk reports
334
333
  * what it found, and {@link buildSite} decides whether that is one.
335
334
  *
336
- * A homepage that declares no `shortcode` has no address (#182), and is
335
+ * A homepage that declares no `shortcode` has no address, and is
337
336
  * reported rather than written: it is the same finding a content page's missing
338
337
  * shortcode produces, and it has to be available in homepage-only mode, where
339
338
  * no other gate runs.
@@ -373,13 +372,13 @@ export function collectHomepages(contentBase, ctx) {
373
372
  * for, and would make homepage-only mode depend on the index, the foreign
374
373
  * manifests and the table universe that mode exists to not build.
375
374
  *
376
- * **Verbatim is the answer to #54, not a gap left by it.** A landing's links
375
+ * **Verbatim is the answer, not a gap.** A landing's links
377
376
  * could not be *resolved* here without giving `homepage` mode the index its
378
377
  * licensing fence exists to not build, so they are **checked** instead:
379
378
  * {@link auditHomepageLinks} reads the `landing:` addresses and the body's
380
379
  * markdown links, and reports a wikilink on the page rather than resolving one.
381
380
  *
382
- * **Its destination is no longer fixed** (#182). The file is written at the
381
+ * **Its destination is no longer fixed**. The file is written at the
383
382
  * note's address, flat at the package's site root, and the page states that
384
383
  * address as its `url` — the same separation of file from URL every other page
385
384
  * has. Nothing is written at `/<package>/` itself: that becomes a redirect the
@@ -417,8 +416,7 @@ export function writeHomepages(outRoot, pages, config) {
417
416
  * - **Addresses** next: a note that has no address — no shortcode to be
418
417
  * addressed by, or no section to be filed under — would silently drop a page.
419
418
  * There is no collision gate beside it: an address is `(type, shortcode)`,
420
- * which is unique within a package by rule, so two pages cannot claim one URL
421
- * (#181).
419
+ * which is unique within a package by rule, so two pages cannot claim one URL.
422
420
  * - **Foreign manifests** last, in two steps. *Unusable* is a file this build
423
421
  * cannot read; *unaddressable* is one it can read but cannot look anything up
424
422
  * in — a distinction worth keeping, because the second surfaces as a pile of
@@ -434,7 +432,7 @@ export function siteGates(pages, findings, { config }) {
434
432
  const out = {
435
433
  // Always empty here: the homepage count is decided in `buildSite`
436
434
  // before the content walk, and a failing count returns without ever
437
- // reaching these gates (#52). Present so every caller reads one shape.
435
+ // reaching these gates. Present so every caller reads one shape.
438
436
  homepages: [],
439
437
  frontmatterLinks: findings.fmLinkFindings ?? [],
440
438
  addressErrors: findings.addressFindings ?? [],
@@ -525,7 +523,7 @@ export function tableUniverse(pages) {
525
523
  byPackage.get(pkg).push({
526
524
  // Package present for a `WHERE … package = "…"` clause,
527
525
  // synthesised rather than authored — see
528
- // {@link searchableFrontmatter} (#56).
526
+ // {@link searchableFrontmatter}.
529
527
  fm: searchableFrontmatter(p.fm, pkg),
530
528
  path: p.relPath,
531
529
  tld: p.tld,
@@ -548,7 +546,7 @@ export function tableUniverse(pages) {
548
546
  * a value YAML can carry, and would abort the serializer.
549
547
  *
550
548
  * Everything else the section declared is passed through. That is the point of
551
- * the function: before #91 both writers transcribed `title` and `banner` by
549
+ * the function: two writers transcribing `title` and `banner` by
552
550
  * name, so the vocabulary lived in three places — the schema that admits a key
553
551
  * and the two writers that copy it — and a key added to the schema alone
554
552
  * validated cleanly and then reached no page. The *schema* is the bound worth
@@ -571,7 +569,7 @@ export function sectionFrontmatter(meta) {
571
569
  /**
572
570
  * The frontmatter a page publishes with.
573
571
  *
574
- * An authored `aliases` is retired (#180) and refused before a build reaches
572
+ * An authored `aliases` is retired and refused before a build reaches
575
573
  * here, which makes this a guard rather than a working path. It was Obsidian's
576
574
  * — a list of *names* a reader might call
577
575
  * the note, which is vault addressing and stays in the vault. Hugo reads
@@ -580,13 +578,13 @@ export function sectionFrontmatter(meta) {
580
578
  * redirects of its own.
581
579
  *
582
580
  * A content page states its own **`url`**, which is its address rather than its
583
- * path (#181). It is written flat under the content mount (#204), so Hugo would
581
+ * path. It is written flat under the content mount, so Hugo would
584
582
  * otherwise publish it at `<mount><type>-<shortcode>/` rather than at the
585
583
  * package-wide address the link manifest records — the same address, one
586
584
  * segment too deep. So the address is stated and the mount does not reach it.
587
585
  *
588
- * **It is stated relative to the site root, and so carries no package base**
589
- * (#217). Hugo resolves a `url` against `baseURL`, whose path is already where
586
+ * **It is stated relative to the site root, and so carries no package base**.
587
+ * Hugo resolves a `url` against `baseURL`, whose path is already where
590
588
  * the package is served — a consumer's Hugo site *is* its package — so writing
591
589
  * `page.url`, which carries the base for every href this build renders, wrote
592
590
  * that base a second time and published every content page a segment too deep
@@ -595,8 +593,8 @@ export function sectionFrontmatter(meta) {
595
593
  * index a wikilink resolves through, and the link manifest — composes
596
594
  * `<base><slug>/`.
597
595
  *
598
- * A content page carries the package the build **derived** (#65). No note
599
- * declares one — `package:` is retired (#56) — so the note's frontmatter alone
596
+ * A content page carries the package the build **derived**. No note
597
+ * declares one — `package:` is retired — so the note's frontmatter alone
600
598
  * would publish a page that does not say which package it belongs to. The
601
599
  * emitted page is what a
602
600
  * theme reads: `breadcrumbs.html` builds its middle crumb from
@@ -623,7 +621,7 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
623
621
  // `package: undefined` is not a value YAML can carry.
624
622
  ...(page.pkg ? { package: page.pkg } : {}),
625
623
  // The address, stated — site-root relative, because Hugo prefixes
626
- // the site's own base to it (#217). `slug` is written beside it
624
+ // the site's own base to it. `slug` is written beside it
627
625
  // because it is the last segment of that address and Hugo's own key
628
626
  // for one; it decides nothing while `url` is present, but a page
629
627
  // that carried only `url` would report a slug Hugo had inferred
@@ -651,9 +649,9 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
651
649
  /**
652
650
  * Where a page is written, relative to the output root.
653
651
  *
654
- * **Flat, under the mount, named by its address** (#204). A content page's URL
652
+ * **Flat, under the mount, named by its address**. A content page's URL
655
653
  * is its address — `/<package>/<type>-<shortcode>/` — and the file is now named
656
- * the same way, so the two agree. It used to be filed into `<section>/` so that
654
+ * the same way, so the two agree. Filing it into `<section>/` so that
657
655
  * Hugo would read a section off its path; a section appears in no address, and
658
656
  * a directory chosen only to satisfy a rendering engine's idea of what a
659
657
  * section is has no business in the note format.
@@ -661,7 +659,7 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
661
659
  * The name is the *whole* address rather than a section-relative half of it, so
662
660
  * two types cannot fight over one file: a `doc` note's `subType` may be spelled
663
661
  * the same as another note's `type`, and `doc-gear.md` and `weapongear-gear.md`
664
- * are distinct whatever the sections used to be.
662
+ * are distinct whatever the sections.
665
663
  *
666
664
  * **A `trees` entry is the exception, and always was.** Those pages preserve
667
665
  * their source layout below a named section — they are a book with chapters,
@@ -714,8 +712,8 @@ export function renderPages(pages, options) {
714
712
  for (const page of pages) {
715
713
  // The page's path in the tree an author edits: below the content root
716
714
  // for a content note, below the tree's own root for a `trees` page. It
717
- // used to be composed as `<section>/<basename>` for a content note,
718
- // which named a directory that was never the note's (#204).
715
+ // is not composed as `<section>/<basename>` for a content note,
716
+ // which named a directory that was never the note's.
719
717
  const src = page.relPath ?? page.rel ?? page.base;
720
718
  const ctx = wikiContext(index, {
721
719
  src,
@@ -765,7 +763,7 @@ export function renderPages(pages, options) {
765
763
  /**
766
764
  * Writes the Hugo sections a published tree declares.
767
765
  *
768
- * **This is where a section lives now, and the only place** (#204). A content
766
+ * **This is where a section lives now, and the only place**. A content
769
767
  * note carries none: it is addressed by `(type, shortcode)` and emitted flat
770
768
  * under the mount, so nothing a page does creates a directory. A site that wants
771
769
  * `/<package>/<prefix><section>/` to answer — with a title, a hero, and whatever
@@ -953,7 +951,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
953
951
  const resolved = config ?? loadPackConfig();
954
952
  const site = resolved.site;
955
953
  const scheme = resolved.publish.address;
956
- // Homepage-only or homepage-plus-content (#55). The floor is the homepage,
954
+ // Homepage-only or homepage-plus-content. The floor is the homepage,
957
955
  // so this decides whether the *content* surfaces are published, never
958
956
  // whether anything is.
959
957
  const publishesContent = publishesContentPages(resolved);
@@ -970,7 +968,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
970
968
  // `<out>/<prefix>/<section>/` publishes at `<base><prefix><section>/`.
971
969
  // Resolved against the repository root for the same reason every configured
972
970
  // path is — so the build reads and writes the same places whatever
973
- // directory it was launched from (#1508).
971
+ // directory it was launched from.
974
972
  const outBase = resolveOutputRoot(resolved.rootDir, site.out);
975
973
  const out =
976
974
  outRoot ? path.resolve(outRoot)
@@ -988,13 +986,13 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
988
986
  const ctx = {
989
987
  packages,
990
988
  // The package every note in the tree belongs to. `package:` is
991
- // retired, so this is the only source of it (#56).
989
+ // retired, so this is the only source of it.
992
990
  contentPackage: resolved.contentPackage,
993
991
  skipDirectories: resolved.skipDirectories,
994
992
  config: resolved,
995
993
  // The corpus, derived once for this build and handed to both
996
994
  // collectors — the homepage pass and the content pass read one answer
997
- // about which files the content is, rather than walking twice (#243).
995
+ // about which files the content is, rather than walking twice.
998
996
  records: indexRecordsFor({
999
997
  contentBase: resolved.paths.content,
1000
998
  config: resolved,
@@ -1002,7 +1000,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1002
1000
  }),
1003
1001
  // Where the package is served, which is where an addressed page
1004
1002
  // publishes: an address is `(type, shortcode)`, a package-wide
1005
- // identity that takes no content mount (#181).
1003
+ // identity that takes no content mount.
1006
1004
  base,
1007
1005
  mount,
1008
1006
  scheme,
@@ -1012,7 +1010,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1012
1010
  const homepages = collected.pages;
1013
1011
 
1014
1012
  // Exactly one homepage, and checked here — before the output tree is
1015
- // cleared and before either mode branches (#52). Before the clear, because
1013
+ // cleared and before either mode branches. Before the clear, because
1016
1014
  // a gate that fired after it would have destroyed a good site to report a
1017
1015
  // bad tree. Before the branch, because the requirement does not vary by
1018
1016
  // mode: `publish.site` chooses whether the *content* surfaces are
@@ -1026,7 +1024,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1026
1024
  contentPackage: resolved.contentPackage,
1027
1025
  });
1028
1026
  // A homepage that cannot be addressed is reported in the same place, and
1029
- // reaches homepage-only mode — which runs no other gate at all (#182).
1027
+ // reaches homepage-only mode — which runs no other gate at all.
1030
1028
  const homepageFindings =
1031
1029
  counted.length ? counted : (
1032
1030
  collected.addressFindings.map((f) => ({
@@ -1051,7 +1049,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1051
1049
 
1052
1050
  // Homepage-only stops here, and stopping is the point: nothing below reads
1053
1051
  // the content tree for pages, so `sohl-kethira-basic` and `harn-adventures`
1054
- // cannot publish one whatever else their `site:` block declares (#55).
1052
+ // cannot publish one whatever else their `site:` block declares.
1055
1053
  if (!publishesContent) {
1056
1054
  return {
1057
1055
  gates: emptyGates(),
@@ -1070,7 +1068,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1070
1068
  const pages = [...content.pages];
1071
1069
  const fmLinkFindings = [...content.fmLinkFindings];
1072
1070
 
1073
- // The homepage is **indexed but not rendered** (#182). Now that it has an
1071
+ // The homepage is **indexed but not rendered**. Now that it has an
1074
1072
  // address, `[[homepage-root|Text]]` is an ordinary wikilink and has to
1075
1073
  // resolve to the page the build publishes — which means the address index
1076
1074
  // must hold it. It still takes no part in `renderPages`: a homepage is
@@ -31,14 +31,14 @@
31
31
  * `type/shortcode` are unique by construction, so they always resolve.
32
32
  * `type/shortcode` is the authored form; the canonical
33
33
  * `package-system-type-shortcode` is set alongside it, which is what a
34
- * cross-package link and every merged foreign entry use (#1499, #59). A
34
+ * cross-package link and every merged foreign entry use. A
35
35
  * cross-package target states its package and usually not its system, so it is
36
36
  * matched by the segments it supplies rather than fetched by an exact key —
37
37
  * one hit resolves, and anything else is a finding.
38
38
  *
39
- * **A page's *name* is not a key** (#180). It was, as one of a set of
39
+ * **A page's *name* is not a key**. It was, as one of a set of
40
40
  * collision-aware fallbacks a bare `[[Name]]` was looked up in — which is what
41
- * made two pages of one type forbidden from sharing a display name (#179). The
41
+ * made two pages of one type forbidden from sharing a display name. The
42
42
  * bare form is retired, so the fallbacks answer nothing and the constraint they
43
43
  * imposed is gone with them.
44
44
  *
@@ -57,7 +57,7 @@ import { systemOf } from "./document-subtypes.mjs";
57
57
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
58
58
  import { hasDocEntry } from "./item-docs.mjs";
59
59
  import { contentPackage } from "./content-package.mjs";
60
- // The declared tag vocabulary (#172), which is where `draft` is stated.
60
+ // The declared tag vocabulary, which is where `draft` is stated.
61
61
  import { isDraftNote } from "./note-vocabulary.mjs";
62
62
 
63
63
  /**
@@ -74,8 +74,7 @@ import { isDraftNote } from "./note-vocabulary.mjs";
74
74
  * @property {string} [sec] The Hugo section a **tree** page is filed under, and
75
75
  * the first segment of the `<sec>/<slug>` address it
76
76
  * is reachable by. A content page has none: it is
77
- * addressed by `(type, shortcode)` and emitted flat
78
- * (#204).
77
+ * addressed by `(type, shortcode)` and emitted flat.
79
78
  * @property {string} base Source file's basename, e.g. `Climbing.md`.
80
79
  * @property {string} url The page's published address.
81
80
  * @property {boolean} [isReadme] Whether a tree page is its directory's
@@ -89,7 +88,7 @@ import { isDraftNote } from "./note-vocabulary.mjs";
89
88
  * @property {Map<string, {url: string, name?: string, draft?: boolean}>} index
90
89
  * Address → page. `draft` says the page
91
90
  * carries the `draft` tag, which marks a
92
- * link *into* it (#183).
91
+ * link *into* it.
93
92
  * @property {Set<string>} ambiguous Short addresses claimed by two
94
93
  * packages, and so deliberately absent
95
94
  * from `index`.
@@ -176,27 +175,27 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
176
175
  // Every package an address may name: this build's own, plus every one a
177
176
  // vendored manifest speaks for. Without it `readQualifier` cannot see the
178
177
  // leading package segment of a canonical address, and `kethira-place-x`
179
- // reads as the unknown type `kethira` (#131).
178
+ // reads as the unknown type `kethira`.
180
179
  const ownPackage = contentPackage();
181
180
  const packages = new Set(ownPackage ? [ownPackage] : []);
182
181
 
183
182
  // `section/slug` is unique by construction, and is now a **tree** page's
184
183
  // address: a `trees` entry keeps its source layout below a named section,
185
184
  // so `dev-docs/testing` is how one is cited. A content page carries no
186
- // section at all (#204) and is addressed by `(type, shortcode)` below —
185
+ // section at all and is addressed by `(type, shortcode)` below —
187
186
  // indexing it here as well would have written `weapongear/weapongear-dagger`,
188
187
  // a key no author could reasonably write.
189
188
  //
190
189
  // A page's name, filename and bare slug were indexed here too, as
191
190
  // collision-aware fallbacks the bare `[[Name]]` form looked up; that form is
192
191
  // retired and nothing consults them, so they are gone and with them the rule
193
- // that two pages of a type may not share a name (#179, #180).
192
+ // that two pages of a type may not share a name.
194
193
  for (const e of entries) {
195
194
  if (typeof e.sec !== "string" || !e.sec) continue;
196
195
  sections.add(e.sec.toLowerCase());
197
196
  // `draft` rides on every key a page is addressable by, because a link
198
- // into a draft note renders marked whichever of them the author wrote
199
- // (#183). It decides nothing about resolution: the page is indexed and
197
+ // into a draft note renders marked whichever of them the author wrote.
198
+ // It decides nothing about resolution: the page is indexed and
200
199
  // published as any other.
201
200
  index.set(`${e.sec}/${e.slug}`.toLowerCase(), {
202
201
  url: e.url,
@@ -243,10 +242,10 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
243
242
  // The canonical address alongside the short one. The short form
244
243
  // stays because a bare `[[skill-lang]]` defaults to the citing
245
244
  // note's own package and must keep resolving unchanged; the
246
- // canonical form is what cross-package links use (#1499).
245
+ // canonical form is what cross-package links use.
247
246
  // The page's package is the configured one — the site collection
248
247
  // resolves it and records it as `pkg`. Never read out of
249
- // frontmatter: `package:` is retired (#56).
248
+ // frontmatter: `package:` is retired.
250
249
  index.set(
251
250
  canonicalKey(
252
251
  e.pkg ?? ownPackage,
@@ -258,7 +257,7 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
258
257
  );
259
258
  if (e.pkg) packages.add(e.pkg);
260
259
  // In Foundry an item and its documentation are two documents, so
261
- // `skill/wpnc` and `docskill/wpnc` are two UUIDs (#1362). Here the
260
+ // `skill/wpnc` and `docskill/wpnc` are two UUIDs. Here the
262
261
  // item note renders as one page which *is* its documentation, so
263
262
  // the two qualifiers alias one URL and an anchor on either is an
264
263
  // ordinary in-page anchor. One authored link, correct in both
@@ -268,7 +267,7 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
268
267
  contentTypes.add(`doc${type}`);
269
268
  index.set(`doc${type}/${shortcode}`.toLowerCase(), value);
270
269
  // The canonical documentation address too, so the page answers
271
- // to the address a bare prose link expands to (#336): body
270
+ // to the address a bare prose link expands to: body
272
271
  // prose is under no system block, so it defaults to `none`, and
273
272
  // a system-bearing type's `none` address is its `doc<type>`
274
273
  // one. In Foundry that names a second document; here it names
@@ -316,7 +315,7 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
316
315
  *
317
316
  * There is deliberately **no `manifestsComplete`**. It used to let a resolver
318
317
  * soften an unresolved cross-package address while any package's manifest was
319
- * missing; #184 retired the softening, since the pack compilers and the link
318
+ * missing; the softening is retired, since the pack compilers and the link
320
319
  * checker never had it and one authored link must not get two verdicts. A
321
320
  * caller still passing it is ignored rather than obeyed.
322
321
  */
@@ -329,7 +328,7 @@ export function wikiContext(built, { src, file, type = null, errors, foreignInde
329
328
  contentTypes: built.contentTypes,
330
329
  packages: built.packages,
331
330
  // The package a link written on this page defaults to when it names
332
- // none (#336). Taken from the resolved configuration, the same source
331
+ // none. Taken from the resolved configuration, the same source
333
332
  // the index's own addresses are built from, so a bare link cannot
334
333
  // resolve against a package the index never keyed.
335
334
  contentPackage: contentPackage(),
@@ -17,7 +17,7 @@
17
17
  * The tables were written in Dataview's query language, chosen when the corpus
18
18
  * lived in an Obsidian vault so a table rendered live while authoring. The vault
19
19
  * is gone, and what remained was a hand-written parser and evaluator for someone
20
- * else's language, kept faithful to semantics nothing checked it against (#246).
20
+ * else's language, kept faithful to semantics nothing checked it against.
21
21
  *
22
22
  * **The query is real SQL, run by DuckDB** — not a dialect maintained here. That
23
23
  * is the whole point: a partial reimplementation would accept some valid SQL and
@@ -39,7 +39,7 @@ import path from "node:path";
39
39
 
40
40
  import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
41
41
  import { parseMarkdownFile } from "./helpers.mjs";
42
- // The record accessors only — see `engine/index-records.mjs` (#243).
42
+ // The record accessors only — see `engine/index-records.mjs`.
43
43
  import { isNoteRecord, noteFile } from "./index-records.mjs";
44
44
 
45
45
  /** Rendered in a cell whose value is absent. */
@@ -64,7 +64,7 @@ const escapeCell = (text) =>
64
64
  * Every `sql` fence in a markdown body, with the position each occupies.
65
65
  *
66
66
  * Positions are 0-based lines into the body as given, which is what a diagnostic
67
- * about a directive needs (#17) and what the expander uses to splice results
67
+ * about a directive needs and what the expander uses to splice results
68
68
  * back in.
69
69
  *
70
70
  * @param {string} markdown - The note body, frontmatter already stripped.
@@ -98,12 +98,12 @@ export function findSqlBlocks(markdown) {
98
98
  query: lines.slice(i + 1, close).join("\n"),
99
99
  // `:allow-empty` says a table selecting nothing is intended.
100
100
  // Spelled on the fence rather than in the query because it is a
101
- // statement about this directive and not part of SQL (#223).
101
+ // statement about this directive and not part of SQL.
102
102
  allowEmpty: args["allow-empty"] === true,
103
103
  sectionLevel: Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2,
104
104
  // Every header argument, so a caller can read one this module makes
105
105
  // no use of — the point of taking a real grammar rather than a
106
- // regex per property (#246).
106
+ // regex per property.
107
107
  args,
108
108
  block: lines.slice(i, close + 1).join("\n"),
109
109
  });
@@ -140,7 +140,7 @@ export function findSqlBlocks(markdown) {
140
140
  * It is `FROM` rather than a fence property naming a file, for two reasons. A
141
141
  * path in authored content is a build artifact's name written into the corpus,
142
142
  * so renaming the artifact means sweeping every note that cites it — the
143
- * coupling #126 exists to undo. And *which dataset a query reads* is what
143
+ * coupling the corpus move exists to undo. And *which dataset a query reads* is what
144
144
  * `FROM` is for: the same rule that keeps `_ref` and `_section` ordinary SQL,
145
145
  * visible where an author is already looking, rather than fence options.
146
146
  *
@@ -184,7 +184,7 @@ export async function openNotesDatabase(records, { dir, dependencies = [] } = {}
184
184
  return {
185
185
  // From the result's schema, not from the rows: a query that
186
186
  // selects nothing still has columns, and reporting *that* is
187
- // what tells a stale query from an empty category (#223).
187
+ // what tells a stale query from an empty category.
188
188
  columnNames: reader.columnNames(),
189
189
  rows: reader
190
190
  .getRowObjects()
@@ -303,6 +303,11 @@ function cellText(value, column) {
303
303
  * authored `ORDER BY` decides the section order too, and one query replaces the
304
304
  * forty near-identical blocks the language used to require.
305
305
  *
306
+ * **A result selecting nothing still renders its header and rule.** The finding
307
+ * is the point, not withholding the output: an authored heading with an empty
308
+ * table under it says the query ran and matched nothing, where a heading with
309
+ * *nothing* under it reads as a page that failed to build.
310
+ *
306
311
  * @param {{columns: string[], rows: object[]}} result - From
307
312
  * {@link runSqlQuery}.
308
313
  * @param {object} [opts]
@@ -315,7 +320,9 @@ export function renderSqlTable(result, { linkable = () => true, sectionLevel = 2
315
320
  const { columns, rows } = result;
316
321
  if (!columns.length) throw new Error("query selects no rendered column");
317
322
 
318
- const groups = [];
323
+ // One group with no rows, so the header and the alignment rule are emitted
324
+ // for a result that selects nothing.
325
+ const groups = rows.length ? [] : [{ section: null, rows: [] }];
319
326
  for (const row of rows) {
320
327
  const section =
321
328
  Object.hasOwn(row, RENDER_ALIASES.section) ?
@@ -362,7 +369,7 @@ export function renderSqlTable(result, { linkable = () => true, sectionLevel = 2
362
369
  * {@link module:engine/content-tables.expandContentTables} is not — nor should
363
370
  * it become so: two of its three callers are synchronous, and one of those,
364
371
  * `renderPages`, is exported. Preparing the results first keeps every one of
365
- * those signatures intact, and it is the shape #243 is heading for anyway —
372
+ * those signatures intact, and it is the shape the index is heading for anyway —
366
373
  * the corpus enumerated once, each pass reading the answer rather than
367
374
  * deriving it again.
368
375
  *
@@ -417,7 +424,8 @@ export async function prepareSqlTables(db, sources, { linkable } = {}) {
417
424
  * Answer every `sql` directive in a content tree.
418
425
  *
419
426
  * The one entry point each pass uses, so the compiler, the link checker and the
420
- * site build cannot disagree about what a table selects — the failure mode #243
427
+ * site build cannot disagree about what a table selects — the failure mode the
428
+ * shared index
421
429
  * describes, where N passes each derive the corpus their own way.
422
430
  *
423
431
  * **Nothing is opened for a tree with no `sql` directive.** The corpus is still
@@ -431,7 +439,7 @@ export async function prepareSqlTables(db, sources, { linkable } = {}) {
431
439
  * @param {readonly string[]} [opts.skipDirectories] - The walk's scope.
432
440
  * @param {object[]} [opts.records] - Index records the caller already derived.
433
441
  * A command that also builds a link index holds them already, and deriving
434
- * them twice is the duplicated-corpus failure #243 is closing.
442
+ * them twice is the duplicated-corpus failure this closes.
435
443
  * @returns {Promise<Map<string, object[]>|undefined>} Results by note path, or
436
444
  * nothing when the tree has no such directive.
437
445
  */
@@ -444,7 +452,7 @@ export async function prepareTreeSqlTables(contentBase, { config, skipDirectorie
444
452
  const indexRecords = records ?? indexRecordsFor({ contentBase, config, skipDirectories });
445
453
 
446
454
  // Which notes carry a directive, discovered over the same corpus every
447
- // other pass reads rather than over a walk of this one's own (#243). The
455
+ // other pass reads rather than over a walk of this one's own. The
448
456
  // body has to be read to find a fence — the index carries no note text —
449
457
  // but *which files* to read is no longer a second answer.
450
458
  //
@@ -464,7 +472,7 @@ export async function prepareTreeSqlTables(contentBase, { config, skipDirectorie
464
472
  // never ships a link the wikilink pass will then report dead.
465
473
  const addresses = new Set(indexRecords.map((record) => record.address?.slug).filter(Boolean));
466
474
  // Each declared dependency's published index, attached as its own schema so
467
- // a table can read `FROM <package>.notes` (#246). Imported here for the
475
+ // a table can read `FROM <package>.notes`. Imported here for the
468
476
  // same cycle reason the index is, and tolerated when absent: a tree with no
469
477
  // `sql` directive never reaches this line, and one whose dependency has not
470
478
  // been fetched already fails earlier with a message naming the fetch.
@@ -16,7 +16,8 @@
16
16
  * questions asked of the *list* rather than of any one map.
17
17
  *
18
18
  * One frozen list, and the two lookups that need to choose among its members
19
- * before a map can be consulted at all. It lived in `note-claims.mjs` until #270,
19
+ * before a map can be consulted at all. It sits here rather than in
20
+ * `note-claims.mjs`,
20
21
  * which is where the *questions* asked of it live — but that module imports
21
22
  * half the engine, so anything needing the bare list had to take all of it, and
22
23
  * `helpers.mjs` could not take it at all: `note-claims.mjs` imports
@@ -42,7 +43,7 @@ import { HM3_DOCUMENT_SUBTYPES } from "../hm3/document-subtypes.mjs";
42
43
  /**
43
44
  * The note-type → document-subtype maps this toolchain ships.
44
45
  *
45
- * Two, since `hm3/` landed (#139) — and it joined this list rather than the
46
+ * Two, since `hm3/` landed — and it joined this list rather than the
46
47
  * claim table growing a second copy of the same fact, which is what the list
47
48
  * was for.
48
49
  *
@@ -72,7 +73,7 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
72
73
  * {@link KNOWN_DOCUMENT_SUBTYPE_MAPS} exists rather than a hand-kept table.
73
74
  *
74
75
  * It exists because an actor note publishes documentation like every other
75
- * system-bearing note (#337). `docEntryTypes` was `itemTypes` plus `macro` and
76
+ * system-bearing note. `docEntryTypes` was `itemTypes` plus `macro` and
76
77
  * the map types, which left a being as the one system-bearing note with no
77
78
  * `none` address — nothing a prose link could land on, since its only address
78
79
  * named the Actor. Composing that set needs to know which types are actors, and
@@ -111,7 +112,7 @@ export function subtypeMapFor(system) {
111
112
  * translation the *schema* check needs, and the reason it needs one.
112
113
  *
113
114
  * A schema artifact is keyed by document subtype; a field declaration is keyed
114
- * by note type. Those were the same string until #78 renamed three of them, and
115
+ * by note type. Those would be the same string but for three renames, and
115
116
  * a check that went on joining them by name simply stopped reporting on
116
117
  * `armorgear` — a warning that vanishes is worse than one that fires, because
117
118
  * nothing says it went.