@heroiclands/package-build 20.3.1 → 20.5.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 (195) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/CONTENT.md +322 -110
  3. package/README.md +21 -10
  4. package/bin/content-build.mjs +204 -92
  5. package/bin/package-build.mjs +48 -15
  6. package/config.mjs +6 -8
  7. package/content-config.mjs +518 -151
  8. package/docs/api.md +1352 -0
  9. package/docs/commands.md +1609 -0
  10. package/docs/configuration.md +1432 -0
  11. package/docs/content-format.md +73 -74
  12. package/docs/diagnostics.md +356 -0
  13. package/docs/getting-started.md +813 -0
  14. package/docs/project-setup.md +469 -0
  15. package/e2e.mjs +4 -4
  16. package/engine/actor-compiler.mjs +64 -61
  17. package/engine/address-charset.mjs +6 -6
  18. package/engine/address-diff.mjs +57 -52
  19. package/engine/anchored-sections.mjs +1 -1
  20. package/engine/anchors.mjs +1 -1
  21. package/engine/base-compiler.mjs +42 -38
  22. package/engine/bundle-notes.mjs +4 -4
  23. package/engine/bundles.mjs +17 -8
  24. package/engine/code-fences.mjs +3 -3
  25. package/engine/compendiums.mjs +8 -8
  26. package/engine/compile-corpus.mjs +4 -5
  27. package/engine/content-address.mjs +24 -24
  28. package/engine/content-charset.mjs +1 -1
  29. package/engine/content-format-check.mjs +24 -25
  30. package/engine/content-format.mjs +8 -8
  31. package/engine/content-html.mjs +202 -0
  32. package/engine/content-icons.mjs +196 -165
  33. package/engine/content-index.mjs +61 -40
  34. package/engine/content-links.mjs +74 -41
  35. package/engine/content-lint.mjs +64 -34
  36. package/engine/content-package.mjs +7 -9
  37. package/engine/content-slug.mjs +2 -2
  38. package/engine/content-tables.mjs +38 -34
  39. package/engine/content-tree.mjs +1 -1
  40. package/engine/derived-fields.mjs +174 -0
  41. package/engine/diagnostics.mjs +3 -4
  42. package/engine/document-subtypes.mjs +6 -7
  43. package/engine/field-reference.mjs +8 -8
  44. package/engine/field-spec.mjs +29 -30
  45. package/engine/folder-notes.mjs +18 -16
  46. package/engine/foreign-catalog.mjs +8 -9
  47. package/engine/foundry-entries.mjs +19 -20
  48. package/engine/frontmatter-lint.mjs +95 -103
  49. package/engine/frontmatter.mjs +11 -11
  50. package/engine/generate.mjs +44 -39
  51. package/engine/helpers.mjs +147 -84
  52. package/engine/homepage.mjs +20 -22
  53. package/engine/ids.mjs +15 -15
  54. package/engine/index-records.mjs +4 -4
  55. package/engine/index.mjs +26 -10
  56. package/engine/item-compiler.mjs +54 -29
  57. package/engine/item-docs.mjs +11 -13
  58. package/engine/item-registry.mjs +13 -13
  59. package/engine/journals.mjs +24 -15
  60. package/engine/macros.mjs +3 -3
  61. package/engine/map-notes.mjs +6 -6
  62. package/engine/metadata-index.mjs +5 -5
  63. package/engine/note-claims.mjs +51 -41
  64. package/engine/note-ids.mjs +5 -5
  65. package/engine/note-package.mjs +4 -4
  66. package/engine/note-renames.mjs +3 -3
  67. package/engine/note-schemas.mjs +3 -8
  68. package/engine/note-vocabulary.mjs +56 -54
  69. package/engine/pack-config.mjs +39 -25
  70. package/engine/pack-router.mjs +0 -0
  71. package/engine/pdf-build.mjs +464 -0
  72. package/engine/pdf-fonts.mjs +420 -0
  73. package/engine/pdf-render.mjs +798 -0
  74. package/engine/pdf-toc.mjs +525 -0
  75. package/engine/prose-config.mjs +9 -9
  76. package/engine/prose-lint.mjs +12 -13
  77. package/engine/region-events.mjs +2 -2
  78. package/engine/retired-fields.mjs +24 -22
  79. package/engine/runtime-only-fields.mjs +1 -1
  80. package/engine/scene-levels.mjs +1 -1
  81. package/engine/scenes.mjs +33 -25
  82. package/engine/schema-check.mjs +21 -21
  83. package/engine/schema-extract.mjs +1 -1
  84. package/engine/site-build.mjs +66 -50
  85. package/engine/site-index.mjs +17 -18
  86. package/engine/sql-tables.mjs +21 -13
  87. package/engine/subtype-registry.mjs +5 -4
  88. package/engine/system-block.mjs +18 -19
  89. package/engine/systems.mjs +2 -2
  90. package/engine/web-wikilinks.mjs +41 -38
  91. package/engine/wikilink-syntax.mjs +16 -16
  92. package/engine/wikilinks.mjs +40 -42
  93. package/hm3/actors.mjs +30 -22
  94. package/hm3/document-subtypes.mjs +5 -5
  95. package/hm3/index.mjs +2 -2
  96. package/hm3/item-builders.mjs +1 -1
  97. package/hm3/item-fields.mjs +3 -3
  98. package/hm3/items.mjs +28 -8
  99. package/hm3/template-priority.mjs +2 -2
  100. package/lang.mjs +3 -3
  101. package/manifest.mjs +17 -20
  102. package/package.json +1 -2
  103. package/release.mjs +66 -6
  104. package/sohl/actors.mjs +36 -28
  105. package/sohl/affiliation-standings.mjs +2 -2
  106. package/sohl/being-info.mjs +5 -5
  107. package/sohl/default-item-art.mjs +5 -5
  108. package/sohl/document-subtypes.mjs +5 -5
  109. package/sohl/index.mjs +3 -3
  110. package/sohl/item-builders.mjs +7 -7
  111. package/sohl/item-fields.mjs +8 -9
  112. package/sohl/items.mjs +28 -6
  113. package/sohl/kb-passes.mjs +5 -5
  114. package/sohl/note-schemas.mjs +12 -12
  115. package/sohl/skill-base.mjs +1 -1
  116. package/types/content-config.d.mts +91 -39
  117. package/types/e2e.d.mts +3 -3
  118. package/types/engine/actor-compiler.d.mts +45 -40
  119. package/types/engine/address-charset.d.mts +6 -6
  120. package/types/engine/address-diff.d.mts +60 -6
  121. package/types/engine/base-compiler.d.mts +27 -21
  122. package/types/engine/bundle-notes.d.mts +3 -3
  123. package/types/engine/bundles.d.mts +10 -1
  124. package/types/engine/code-fences.d.mts +3 -3
  125. package/types/engine/compendiums.d.mts +3 -3
  126. package/types/engine/compile-corpus.d.mts +1 -1
  127. package/types/engine/content-address.d.mts +20 -20
  128. package/types/engine/content-format-check.d.mts +6 -6
  129. package/types/engine/content-format.d.mts +2 -2
  130. package/types/engine/content-html.d.mts +78 -0
  131. package/types/engine/content-icons.d.mts +139 -92
  132. package/types/engine/content-index.d.mts +59 -15
  133. package/types/engine/content-links.d.mts +8 -8
  134. package/types/engine/content-lint.d.mts +8 -6
  135. package/types/engine/content-package.d.mts +6 -8
  136. package/types/engine/content-tables.d.mts +49 -18
  137. package/types/engine/derived-fields.d.mts +101 -0
  138. package/types/engine/diagnostics.d.mts +2 -2
  139. package/types/engine/document-subtypes.d.mts +3 -3
  140. package/types/engine/field-spec.d.mts +37 -39
  141. package/types/engine/folder-notes.d.mts +4 -5
  142. package/types/engine/foreign-catalog.d.mts +3 -3
  143. package/types/engine/foundry-entries.d.mts +6 -7
  144. package/types/engine/frontmatter-lint.d.mts +10 -9
  145. package/types/engine/frontmatter.d.mts +7 -7
  146. package/types/engine/generate.d.mts +7 -7
  147. package/types/engine/helpers.d.mts +128 -56
  148. package/types/engine/homepage.d.mts +16 -18
  149. package/types/engine/ids.d.mts +13 -13
  150. package/types/engine/index-records.d.mts +3 -3
  151. package/types/engine/index.d.mts +6 -0
  152. package/types/engine/item-compiler.d.mts +21 -5
  153. package/types/engine/item-docs.d.mts +2 -2
  154. package/types/engine/item-registry.d.mts +6 -6
  155. package/types/engine/journals.d.mts +12 -3
  156. package/types/engine/map-notes.d.mts +2 -2
  157. package/types/engine/metadata-index.d.mts +4 -4
  158. package/types/engine/note-claims.d.mts +30 -22
  159. package/types/engine/note-ids.d.mts +4 -4
  160. package/types/engine/note-package.d.mts +1 -1
  161. package/types/engine/note-renames.d.mts +3 -3
  162. package/types/engine/note-vocabulary.d.mts +31 -203
  163. package/types/engine/pack-config.d.mts +7 -7
  164. package/types/engine/pack-router.d.mts +1 -1
  165. package/types/engine/pdf-build.d.mts +42 -0
  166. package/types/engine/pdf-fonts.d.mts +30 -0
  167. package/types/engine/pdf-render.d.mts +144 -0
  168. package/types/engine/pdf-toc.d.mts +114 -0
  169. package/types/engine/prose-config.d.mts +9 -9
  170. package/types/engine/prose-lint.d.mts +3 -4
  171. package/types/engine/region-events.d.mts +2 -2
  172. package/types/engine/retired-fields.d.mts +10 -9
  173. package/types/engine/scenes.d.mts +10 -1
  174. package/types/engine/schema-check.d.mts +13 -13
  175. package/types/engine/site-build.d.mts +52 -25
  176. package/types/engine/site-index.d.mts +3 -4
  177. package/types/engine/sql-tables.d.mts +11 -5
  178. package/types/engine/subtype-registry.d.mts +3 -3
  179. package/types/engine/system-block.d.mts +3 -3
  180. package/types/engine/web-wikilinks.d.mts +7 -7
  181. package/types/engine/wikilink-syntax.d.mts +17 -17
  182. package/types/engine/wikilinks.d.mts +14 -14
  183. package/types/hm3/actors.d.mts +9 -1
  184. package/types/hm3/document-subtypes.d.mts +1 -1
  185. package/types/hm3/items.d.mts +9 -2
  186. package/types/hm3/template-priority.d.mts +1 -1
  187. package/types/manifest.d.mts +8 -8
  188. package/types/release.d.mts +15 -4
  189. package/types/sohl/actors.d.mts +12 -4
  190. package/types/sohl/affiliation-standings.d.mts +2 -2
  191. package/types/sohl/being-info.d.mts +2 -2
  192. package/types/sohl/document-subtypes.d.mts +1 -1
  193. package/types/sohl/items.d.mts +9 -2
  194. package/types/sohl/note-schemas.d.mts +1 -1
  195. package/MIGRATING.md +0 -608
@@ -15,43 +15,43 @@
15
15
  * Linting a content tree's **addresses** — the rules every package's notes are
16
16
  * authored against, wherever those notes live.
17
17
  *
18
- * These rules used to live in the SoHL repository's `utils/`, which had two
19
- * consequences and no upside (#20). `thalorna` and `kethira` notes were checked
18
+ * These rules do not live in a consumer's `utils/`, which has two
19
+ * consequences and no upside. `thalorna` and `kethira` notes were checked
20
20
  * by nothing at all, so the packages most likely to carry authoring mistakes
21
21
  * were the ones nothing inspected. And one rule with two implementations can
22
22
  * disagree without anything detecting it, which the canonical-separator
23
23
  * handling already did once on each side.
24
24
  *
25
- * Four rules, all about a note's identity:
25
+ * Five rules, all about a note's identity:
26
26
  *
27
- * 1. **Shape** — a `shortcode` is strictly ASCII-alphanumeric. It is the
28
- * identity key referenced from saved world data, and it is half of the
27
+ * 1. **Shape** — a `shortcode` is strictly lowercase ASCII-alphanumeric. It is
28
+ * the identity key referenced from saved world data, and it is half of the
29
29
  * `type-shortcode` address, whose parse depends on the separating hyphen
30
30
  * being the only hyphen in the string.
31
31
  * 2. **Uniqueness** — `(type, shortcode)` names one note.
32
32
  * 3. **The package's own address** — exactly one note claims `/<package>/`,
33
- * which is {@link checkHomepageCount} (#52). It belongs here for the same
33
+ * which is {@link checkHomepageCount}. It belongs here for the same
34
34
  * reason the other two do: it is a statement about which note holds which
35
35
  * address, it needs no `site:` configuration to decide, and a package with
36
36
  * no front page is misconfigured whether or not anyone runs a site build.
37
37
  * 4. **Vacated addresses** — a `renamedFrom:` entry names an address this note
38
- * used to hold and nothing holds now (#278). It is the same statement as
38
+ * once held and nothing holds now. It is the same statement as
39
39
  * rule 2 read backwards, and it needs the same whole-tree view: an entry can
40
40
  * only be checked against every *other* note's address, and two notes
41
41
  * claiming one predecessor is the uniqueness rule applied to the past.
42
+ * 5. **The vocabulary a package's kind leaves it** — a package compiling no
43
+ * Foundry documents publishes `doc` and `homepage` notes and nothing else.
44
+ * It is here rather than with the claim check in `note-claims.mjs` because
45
+ * that check runs at compile, and this is the case where no compile runs.
42
46
  *
43
47
  * **Nothing here writes.** A check reports and an author fixes.
44
48
  *
45
- * **A third rule was retired (#79).** Every note used to be required to repeat
46
- * its own `type-shortcode` address in the top-level `aliases:` list. That
47
- * served exactly one reader — **Obsidian**, so `[[type-shortcode]]` resolved in
48
- * the editor — and nothing else ever read it: both resolvers parse the hyphen
49
- * qualifier themselves. The project no longer authors in Obsidian, so the rule
50
- * required a line of frontmatter per note for a reader that does not exist. The
51
- * field itself is retired now (#180), refused from `retired-fields.mjs`. Removing it was
52
- * verified output-neutral beforehand: across 1,735 stripped notes,
53
- * `package compile` produced byte-identical `build/packs-json` and the site
54
- * build byte-identical `site/content`.
49
+ * **There is deliberately no third rule** requiring every note to repeat its
50
+ * own `type-shortcode` address in a top-level `aliases:` list. It would serve
51
+ * exactly one reader — **Obsidian**, so `[[type-shortcode]]` resolves in the
52
+ * editor — and nothing in the build reads it: both resolvers parse the hyphen
53
+ * qualifier themselves. The field is retired, refused from
54
+ * `retired-fields.mjs`.
55
55
  *
56
56
  * **What is deliberately absent.** Corpus reachability — "every Rules document
57
57
  * is reachable from the book's root" — is a statement about what one package
@@ -65,25 +65,30 @@ import fs from "node:fs";
65
65
  import path from "node:path";
66
66
 
67
67
  import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
68
+ // The kind that compiles nothing, and the vocabulary that leaves a tree with.
69
+ import { DOCUMENTATION_KIND, compilesFoundryDocuments } from "../content-config.mjs";
70
+ import { DOCUMENTATION_NOTE_TYPES } from "./note-claims.mjs";
68
71
  import { positionInFrontmatter } from "./diagnostics.mjs";
69
72
  import { assertStatedScope } from "./helpers.mjs";
70
- // The corpus, read from the one pass that derives it (#243).
73
+ // The corpus, read from the one pass that derives it.
71
74
  import { authoredFrontmatter, indexRecordsFor, isNoteRecord, noteFile } from "./content-index.mjs";
72
75
  import { checkHomepageCount, isHomepage } from "./homepage.mjs";
73
76
  import { declaresRenamedFrom, renamedFrom, renamedFromEntries } from "./note-renames.mjs";
74
77
 
75
78
  /**
76
- * The shape every `shortcode` must match: ASCII letters and digits only.
79
+ * The shape every `shortcode` must match: lowercase ASCII letters and digits
80
+ * only.
77
81
  *
78
82
  * This is {@link ADDRESS_SEGMENT_PATTERN}, not a second copy of it. A shortcode
79
83
  * is the last segment of a canonical address, and the rule it is held to is the
80
84
  * rule *every* segment is held to — so the two are one constant rather than two
81
- * free to drift apart (#59). The name survives because this is where the rule
85
+ * free to drift apart. The name survives because this is where the rule
82
86
  * is applied to a note.
83
87
  *
84
- * Case is deliberately **not** constrained: hundreds of authored shortcodes are
85
- * mixed-case and collide with nothing, so tightening that is a separate
86
- * decision from this one.
88
+ * Case is held to that rule with no exception: two shortcodes differing only
89
+ * in case are two names nobody can tell apart, and `canonicalKey` lowercases
90
+ * every address it builds regardless, so a mixed-case shortcode addresses the
91
+ * same document as its lowercase spelling.
87
92
  *
88
93
  * A consuming system's *runtime* keeps its own copy of this pattern — it cannot
89
94
  * import a build-time dependency into shipped code — and is expected to pin the
@@ -112,7 +117,7 @@ export function isValidShortcode(value) {
112
117
  * `Templates/`, a `README`, a repository's own `CLAUDE.md` — has no type, is
113
118
  * neither addressed nor addressable, and would fail rules it can never satisfy.
114
119
  *
115
- * **Read from the index, not from a walk of this pass's own** (#243). The
120
+ * **Read from the index, not from a walk of this pass's own**. The
116
121
  * `lint` command already derives the index — its link check and its `sql`
117
122
  * tables are built from it — and then walked the tree a second time to get
118
123
  * here, so one command held two answers to "which files are the corpus?" and
@@ -162,7 +167,7 @@ function collectNotes(contentBase, { skipDirectories, config, records, problems
162
167
  }
163
168
 
164
169
  /**
165
- * What one note's `renamedFrom:` says, checked against itself (#278).
170
+ * What one note's `renamedFrom:` says, checked against itself.
166
171
  *
167
172
  * The entries a note can be wrong about on its own: a value that is not a
168
173
  * shortcode, one naming the address the note holds *now*, one written twice.
@@ -229,8 +234,9 @@ function checkRenamedFrom({ fm, file }, raw) {
229
234
  ...at(value),
230
235
  severity: "error",
231
236
  message:
232
- `\`renamedFrom: ${value}\` is not strictly alphanumeric, so ` +
233
- `it is not an address this package ever published a ` +
237
+ `\`renamedFrom: ${value}\` is not strictly alphanumeric ` +
238
+ `lowercase letters and digits only (${ADDRESS_SEGMENT_PATTERN.source}) — ` +
239
+ `so it is not an address this package ever published — a ` +
234
240
  `shortcode is held to one charset whether it is current or past`,
235
241
  });
236
242
  continue;
@@ -276,7 +282,7 @@ function checkRenamedFrom({ fm, file }, raw) {
276
282
  * @param {object} [opts.config] - The resolved build configuration, which the
277
283
  * corpus is derived against.
278
284
  * @param {readonly object[]} [opts.records] - Index records the caller already
279
- * derived, so a command reads one corpus (#243).
285
+ * derived, so a command reads one corpus.
280
286
  * @param {object[]} [opts.problems] - Collects the notes the index cannot
281
287
  * record, instead of letting one of them silence the lint.
282
288
  * @returns {{findings: Array<{file: string, line?: number, column?: number,
@@ -289,6 +295,12 @@ export function lintContentTree(
289
295
  ) {
290
296
  const findings = [];
291
297
  const notes = collectNotes(contentBase, { skipDirectories, config, records, problems });
298
+ // Whether this package's note vocabulary is the narrowed one. Asked of the
299
+ // configuration once rather than per note, and defaulted to the wide
300
+ // vocabulary when a caller supplies none — an unconfigured lint holds a
301
+ // tree to the rules every package shares.
302
+ const narrowed =
303
+ Boolean(config) && !compilesFoundryDocuments(/** @type {{packageKind: string}} */ (config));
292
304
 
293
305
  /** @type {Map<string, Array<{file: string, absPath: string}>>} */
294
306
  const byKey = new Map();
@@ -303,6 +315,23 @@ export function lintContentTree(
303
315
  // tree costs one pass rather than two.
304
316
  const raw = () => fs.readFileSync(absPath, "utf8");
305
317
 
318
+ // Rule 5, and it is the whole of the check for a package that compiles
319
+ // nothing: no pass downstream would report the note, because the pass
320
+ // that reports an unclaimed type is a compile pass and none runs.
321
+ const type = typeof fm.type === "string" ? fm.type.trim() : "";
322
+ if (narrowed && type && !DOCUMENTATION_NOTE_TYPES.has(type)) {
323
+ findings.push({
324
+ file,
325
+ ...positionInFrontmatter(raw(), "type", type),
326
+ severity: "error",
327
+ message:
328
+ `\`type: ${type}\` compiles to a Foundry document, and a ` +
329
+ `\`${DOCUMENTATION_KIND}\` package compiles none — so the ` +
330
+ `note has no destination. Its vocabulary is ` +
331
+ `${[...DOCUMENTATION_NOTE_TYPES].map((t) => `\`${t}\``).join(" and ")}`,
332
+ });
333
+ }
334
+
306
335
  // Before the keyless `continue` below, because a note declaring a
307
336
  // rename while carrying no address of its own is exactly one of the
308
337
  // things this reports — and reaching it after the skip would mean it
@@ -336,7 +365,8 @@ export function lintContentTree(
336
365
  ...positionInFrontmatter(raw(), "shortcode", String(shortcode)),
337
366
  severity: "error",
338
367
  message:
339
- `shortcode "${shortcode}" is not strictly alphanumeric; it ` +
368
+ `shortcode "${shortcode}" is not strictly alphanumeric ` +
369
+ `lowercase letters and digits only (${ADDRESS_SEGMENT_PATTERN.source}); it ` +
340
370
  `is the identity key and half of the ` +
341
371
  `"${fm.type}-${shortcode}" address, whose parse needs the ` +
342
372
  `separator to be the only hyphen`,
@@ -348,17 +378,17 @@ export function lintContentTree(
348
378
  // what a tree that failed to check out produces — so the lint would go
349
379
  // green on the one state it most needs to catch.
350
380
  //
351
- // The state that catches is an **empty walk**, not an empty key set (#77).
381
+ // The state that catches is an **empty walk**, not an empty key set.
352
382
  // Notes may be keyless: a folder document carries no `shortcode`, and a
353
383
  // tree of them is populated, correct, and unkeyed. Reporting that as a
354
384
  // missing checkout trains its author to stop reading the output — the one
355
385
  // thing this guard needs them to do. A tree holding notes is therefore a
356
386
  // tree; only a tree holding none is the absent one.
357
387
  //
358
- // The homepage used to be the headline example, because it was addressed
388
+ // The homepage is not the headline example, though it was once addressed
359
389
  // by the package rather than by a slug — so a `publish.site: homepage`
360
390
  // package had a tree with exactly one note and no key at all. It carries an
361
- // address like every other note now (#182); the guard is unchanged, because
391
+ // address like every other note now; the guard is unchanged, because
362
392
  // what it reads was never the key count.
363
393
  if (notes.length === 0) {
364
394
  findings.push({
@@ -401,8 +431,8 @@ export function lintContentTree(
401
431
  }
402
432
  }
403
433
 
404
- // The two questions about a declared rename that need the whole tree
405
- // (#278). Both are the uniqueness rule above, applied to the past: an
434
+ // The two questions about a declared rename that need the whole tree.
435
+ // Both are the uniqueness rule above, applied to the past: an
406
436
  // address has one holder, so it has one successor and it cannot be both
407
437
  // vacated and occupied.
408
438
  for (const [claim, claimants] of claimedPredecessors) {
@@ -16,7 +16,7 @@
16
16
  * ships them — read from the repository's `package-build.config.yaml`.
17
17
  *
18
18
  * Both values are **derived**, not declared: `config.mjs` is the single place
19
- * the configuration is resolved (#1508). This module survives as the import
19
+ * the configuration is resolved. This module survives as the import
20
20
  * path the link resolver and the compilers have always used, so that the values
21
21
  * can still be mocked in one place and so no consumer has to learn a new
22
22
  * spelling for them.
@@ -35,13 +35,11 @@ import { loadPackConfig } from "./pack-config.mjs";
35
35
  * repository's identity in the address space, not a switch — and never dead
36
36
  * configuration, whatever else changes.
37
37
  *
38
- * It was also, until #56, a **selector**: a note declared the same value in its
39
- * `package:` frontmatter and the compilers kept the ones that matched. Every
40
- * content tree is single-package each is single-sourced in the repository that
41
- * ships it so the field restated this constant once per note while a value
42
- * that matched nothing filtered the whole tree out in silence. That field is
43
- * retired and declaring it now fails the build; this value stays, here, where
44
- * it is declared once.
38
+ * It is not a **selector**. Every content tree is single-package each is
39
+ * single-sourced in the repository that ships it so a `package:` field on a
40
+ * note would restate this constant once per note, and a value matching nothing
41
+ * would filter the whole tree out in silence. That field is refused, and this
42
+ * value stays here, declared once.
45
43
  *
46
44
  * Stable across compilation targets. If this content were ever compiled for a
47
45
  * second game system, it would still be published as `sohl` — only the Foundry
@@ -65,7 +63,7 @@ export function contentPackage() {
65
63
  * here: a note is published under `sohl` and its documents are addressed as
66
64
  * `Compendium.sohl.<pack>.<Type>.<id>`. In `sohl-thalorna` the two differ
67
65
  * (`thalorna` vs `sohl-thalorna`), which is why they are separate values rather
68
- * than one — treating them as interchangeable is what #1498 was.
66
+ * than one — treating them as interchangeable is the mistake.
69
67
  *
70
68
  * Configured rather than read from the manifest so the link resolver stays
71
69
  * filesystem-free and unit-testable. `assertPackageIdMatchesManifestFile` in
@@ -17,9 +17,9 @@
17
17
  * {@link slugify} reduces a piece of prose — a heading, a document name — to a
18
18
  * URL-safe token. It is **not** how a page is addressed: a note's URL is its
19
19
  * address, `type-shortcode`, derived in `engine/content-address.mjs` and
20
- * touching no display string at all (#181).
20
+ * touching no display string at all.
21
21
  *
22
- * That used to be the other way round. This module carried a `contentSlug` that
22
+ * It is not the other way round: a `contentSlug` here that
23
23
  * derived a page's URL from `name.full`, abbreviating it through a table of 200
24
24
  * words so the result stayed short, and a `findSlugCollisions` to catch the two
25
25
  * notes that would then claim one URL. Its own header justified the readability
@@ -59,7 +59,7 @@
59
59
 
60
60
  // The fence syntax is stated once, in `code-fences.mjs`, so the scanner that
61
61
  // *skips* code and the one that expands a `dataview` block cannot disagree
62
- // about where a block begins and ends (#1505).
62
+ // about where a block begins and ends.
63
63
  import { FENCE_LINE } from "./code-fences.mjs";
64
64
 
65
65
  /** What a cell shows when its expression resolves to nothing. */
@@ -1059,17 +1059,32 @@ export function renderContentTable(spec, rows, linkable, self) {
1059
1059
  /* ------------------------------------------------------------------------ */
1060
1060
 
1061
1061
  /**
1062
- * Expand every fenced `dataview` block in a markdown body.
1062
+ * The `WHERE` clause of a query, as authored, for a message that has to name
1063
+ * what matched nothing.
1064
+ *
1065
+ * The parsed spec holds an expression tree, and rendering that back to text
1066
+ * would be a second dialect of the query language to keep in step. The authored
1067
+ * line is what the author wrote and what they will edit, so it is what the
1068
+ * finding quotes.
1069
+ *
1070
+ * @param {string} query - The fence's contents.
1071
+ * @returns {string} The clause, or "" when the query has none.
1072
+ */
1073
+ function whereText(query) {
1074
+ const match = /^\s*WHERE\s+(.+?)\s*$/im.exec(String(query ?? ""));
1075
+ return match ? `\`${match[1]}\`` : "";
1076
+ }
1077
+
1078
+ /**
1079
+ * Expand every fenced `dataview` and `sql` block in a markdown body.
1063
1080
  *
1064
1081
  * A block that cannot be honoured — malformed or unsupported — is left in the
1065
1082
  * body verbatim and reported in `errors`, so the failure is visible in the
1066
1083
  * output as well as on the console. Every other code fence, and every code
1067
1084
  * span, is left alone (that is how the syntax is documented).
1068
1085
  *
1069
- * A query that matches **no** note is not an error: it renders as an empty
1070
- * table (headers only), which is what the author already sees in Obsidian, and
1071
- * a category with no content yet is a normal state of the corpus rather than a
1072
- * broken build.
1086
+ * A query that selects **no** note is an error unless the fence says
1087
+ * `allow-empty`, which states that an empty table is the intended result.
1073
1088
  *
1074
1089
  * @param {string} markdown - The note body, frontmatter already stripped.
1075
1090
  * @param {object} ctx
@@ -1080,30 +1095,20 @@ export function renderContentTable(spec, rows, linkable, self) {
1080
1095
  * @param {string} [ctx.source] - The note being expanded, for error reports.
1081
1096
  * @param {ContentTableDoc} [ctx.self] - The note being expanded, as a searchable
1082
1097
  * doc: what a query's `this` reads.
1098
+ * @param {object[]} [ctx.sqlTables] - This note's prepared `sql` results, in
1099
+ * document order, from
1100
+ * {@link module:engine/sql-tables.prepareSqlTables}. An `sql` directive with
1101
+ * no prepared result is an error: nothing here runs a query.
1083
1102
  * @returns {{markdown: string, errors: Array<{source: string, directive: string,
1084
- * reason: string, line: number}>, lineMap: Array<{line: number,
1085
- * generated: boolean}>}} `lineMap` is parallel to the emitted lines and says
1086
- * which authored line each came from, so a diagnostic about the expanded
1087
- * body can name an authored position (#17). An `errors` entry carries the
1088
- * 0-based line of the directive that failed, for the same reason.
1103
+ * reason: string, line: number, column?: number}>,
1104
+ * warnings: Array<{source: string, line: number, column: number,
1105
+ * reason: string}>, lineMap: Array<{line: number, generated: boolean}>}}
1106
+ * `lineMap` is parallel to the emitted lines and says which authored line
1107
+ * each came from, so a diagnostic about the expanded body can name an
1108
+ * authored position. An `errors` entry carries the 0-based line of the
1109
+ * directive that failed, for the same reason. `warnings` holds one entry per
1110
+ * `dataview` directive the body still authors.
1089
1111
  */
1090
- /**
1091
- * The `WHERE` clause of a query, as authored, for a message that has to name
1092
- * what matched nothing.
1093
- *
1094
- * The parsed spec holds an expression tree, and rendering that back to text
1095
- * would be a second dialect of the query language to keep in step. The authored
1096
- * line is what the author wrote and what they will edit, so it is what the
1097
- * finding quotes.
1098
- *
1099
- * @param {string} query - The fence's contents.
1100
- * @returns {string} The clause, or "" when the query has none.
1101
- */
1102
- function whereText(query) {
1103
- const match = /^\s*WHERE\s+(.+?)\s*$/im.exec(String(query ?? ""));
1104
- return match ? `\`${match[1]}\`` : "";
1105
- }
1106
-
1107
1112
  export function expandContentTables(
1108
1113
  markdown,
1109
1114
  {
@@ -1121,12 +1126,12 @@ export function expandContentTables(
1121
1126
  let sqlOrdinal = 0;
1122
1127
  // One entry per `dataview` directive still authored, so a caller can say how
1123
1128
  // much of the corpus is still on the retiring language without this pass
1124
- // failing a build over it (#246).
1129
+ // failing a build over it.
1125
1130
  const warnings = [];
1126
1131
  const lines = String(markdown ?? "").split("\n");
1127
1132
  const out = [];
1128
1133
  // Which authored line each emitted line came from, so a later pass can
1129
- // report an authored position for text this one moved (#17). A generated
1134
+ // report an authored position for text this one moved. A generated
1130
1135
  // row has no authored line of its own, so it is blamed on the directive
1131
1136
  // that produced it — the thing an author can actually edit — and marked,
1132
1137
  // because its columns mean nothing.
@@ -1203,8 +1208,7 @@ export function expandContentTables(
1203
1208
  line: i,
1204
1209
  column: indent.length + 1,
1205
1210
  reason:
1206
- "`dataview` content tables are being replaced by `sql` over the " +
1207
- "content index (#246)",
1211
+ "`dataview` content tables are being replaced by `sql` over the " + "content index",
1208
1212
  });
1209
1213
  // `dataview allow-empty` says a table selecting nothing is the intended
1210
1214
  // state. Spelled on the fence rather than in the query, because it is a
@@ -1219,7 +1223,7 @@ export function expandContentTables(
1219
1223
  // a header with nothing under it makes that indistinguishable from
1220
1224
  // a category that is legitimately empty. Eight tables in `sohl`'s
1221
1225
  // `Rules/Bestiary.md` published that way for months after the
1222
- // `creature` → `being` rename, and no build said a word (#223).
1226
+ // `creature` → `being` rename, and no build said a word.
1223
1227
  if (rows.length === 0 && !allowEmpty) {
1224
1228
  errors.push({
1225
1229
  source,
@@ -1239,7 +1243,7 @@ export function expandContentTables(
1239
1243
  directive: block.join("\n"),
1240
1244
  reason: err.message,
1241
1245
  // The fence's own line, so a table error is as locatable as
1242
- // everything else the build reports (#17).
1246
+ // everything else the build reports.
1243
1247
  line: i,
1244
1248
  });
1245
1249
  block.forEach((text, k) => emit(text, i + k));
@@ -20,7 +20,7 @@
20
20
  * notices — the LevelDB packs are written, the deploy mirrors them, and the
21
21
  * defect surfaces as "my compendiums are blank" long after the build went green.
22
22
  *
23
- * `assets/content/` is this repository's own source (#1445), but an empty or
23
+ * `assets/content/` is this repository's own source, but an empty or
24
24
  * partial tree is still possible — a bad checkout, or a sparse clone. This
25
25
  * counts what is actually there so the build can refuse.
26
26
  */
@@ -0,0 +1,174 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * `system` keys a note may **never** author, because the *compiler* writes them
16
+ * from the note itself.
17
+ *
18
+ * The third of three refusals that read alike and state different facts:
19
+ *
20
+ * | module | refuses a field | because |
21
+ * | --- | --- | --- |
22
+ * | `retired-fields.mjs` | a note may no longer declare | it has been withdrawn |
23
+ * | `runtime-only-fields.mjs` | a note never could | the *document* writes it in play |
24
+ * | this one | a note never could | the *compiler* writes it, from the note |
25
+ *
26
+ * The distinction between the last two is where the value comes from, and it
27
+ * decides what an author is told. A runtime-only field has no compile-time
28
+ * value at all, so the fix is to delete the key. A derived one **does** have a
29
+ * compile-time value — it is already being written, from somewhere else in the
30
+ * same note — so the fix is to move the content to wherever the compiler reads
31
+ * it from, and the message has to say where that is.
32
+ *
33
+ * ## Why an authored value cannot be the source of anything
34
+ *
35
+ * The compiler writes the key unconditionally. An authored one is therefore at
36
+ * best redundant, and at worst the wrong type in a shipped document: SoHL's
37
+ * `docHtml` holds a `@UUID` pointing at the JournalEntry a note's prose
38
+ * compiled into, so a note writing prose there ships a string where every
39
+ * reader expects a pointer, with nothing to report it.
40
+ *
41
+ * Either way the text is not reaching a reader the way its author intended. It
42
+ * was written as a description, and the field it was written into is not where
43
+ * descriptions live — the note's own body is.
44
+ *
45
+ * ## Declared by the compiler, not named here
46
+ *
47
+ * A derived key is a fact about a *pass*, not about a schema: it exists because
48
+ * that compiler chose to write it. So the list is a static on the compiler and
49
+ * this module knows no key names, which is what lets a second system declare
50
+ * its own — HM3 derives `description` from an anchored prose section exactly as
51
+ * SoHL derives `docHtml` from the body.
52
+ *
53
+ * @module
54
+ */
55
+
56
+ import { getFrontmatter } from "./frontmatter.mjs";
57
+ import { locateFrontmatterKey } from "./retired-fields.mjs";
58
+ import { SYSTEM_DATA_KEY, systemData } from "./system-block.mjs";
59
+
60
+ /**
61
+ * What a note authoring a derived key is told, in one place.
62
+ *
63
+ * Shared by every caller that can meet one, so an author meets the same
64
+ * sentence whichever runs first. It names the correction rather than only the
65
+ * fault, because "delete this" is not the fix here: the text is wanted, and
66
+ * there is somewhere it belongs.
67
+ *
68
+ * @param {string} key - The **whole key the note wrote**, from the region it
69
+ * sits in down to the field: `sohl.system.docHtml`. Composed by the caller,
70
+ * because only it knows where it found the value.
71
+ * @param {string} from - Where the compiler reads the value from, completing
72
+ * "the compiler writes it from …". The declaration carries it.
73
+ * @param {string} [file] - The note's path, named in the message. Omit it where
74
+ * the caller emits through a diagnostic, whose locator already starts the
75
+ * line — repeating it prints the path twice.
76
+ * @returns {string} The message, unpunctuated at the end as a finding is.
77
+ */
78
+ export function derivedMessage(key, from, file) {
79
+ return (
80
+ `\`${key}:\` is written by the compiler, not authored` +
81
+ (file ? ` — ${file}` : "") +
82
+ `. It is derived from ${from}, and the compiler writes it ` +
83
+ `unconditionally, so an authored value is overwritten or ships the ` +
84
+ `wrong type. Move the text to ${from} and delete the key`
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The derived keys a note actually writes, in declaration order.
90
+ *
91
+ * **Presence is the whole test**, as it is for a retired or a runtime-only
92
+ * field: an authored empty string is as much a claim on the key as prose is,
93
+ * and it is the same belief the message exists to correct. So the question is
94
+ * whether the path resolves to anything at all, never whether the value is a
95
+ * usable one.
96
+ *
97
+ * @param {object|null|undefined} fm - Parsed frontmatter.
98
+ * @param {readonly {key: string, from: string}[]} [keys] - What the compiler
99
+ * derives. A pass that derives nothing passes.
100
+ * @param {object} options - Options.
101
+ * @param {string} options.block - The system block to look in.
102
+ * @returns {{key: string, from: string}[]} The offending declarations.
103
+ */
104
+ export function authoredDerivedKeys(fm, keys, { block } = {}) {
105
+ if (!fm || typeof fm !== "object" || !block) return [];
106
+ return derivedIn(systemData(fm, block), keys);
107
+ }
108
+
109
+ /**
110
+ * The same question asked of a `system` block directly.
111
+ *
112
+ * A note's own block is reached through {@link authoredDerivedKeys}, but it is
113
+ * not the only place an author writes one: an actor note's `items:` entries
114
+ * carry a `system:` overlay that is deep-merged onto the template verbatim,
115
+ * with no field declaration in the path at all. That overlay is a `system`
116
+ * block by every meaning except where it sits, and a `docHtml` written there
117
+ * ships exactly as one written on the item's own note.
118
+ *
119
+ * @param {Record<string, unknown>|null|undefined} data - The authored `system`
120
+ * data.
121
+ * @param {readonly {key: string, from: string}[]} [keys] - What the compiler
122
+ * derives.
123
+ * @returns {{key: string, from: string}[]} The offending declarations.
124
+ */
125
+ export function derivedIn(data, keys) {
126
+ if (!data || typeof data !== "object") return [];
127
+ return (keys ?? []).filter(
128
+ (entry) =>
129
+ typeof entry?.key === "string" &&
130
+ entry.key !== "" &&
131
+ getFrontmatter(data, entry.key, undefined) !== undefined,
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Refuse a note that authors any key its compiler derives.
137
+ *
138
+ * Refused rather than reported, for the reason its two siblings are: the note
139
+ * is not compiled, so nothing it would have emitted reaches a pack, and each
140
+ * caller counts the refused note and emits a located diagnostic — a refusal is
141
+ * never a silent skip.
142
+ *
143
+ * The **first** offending key is thrown on. A note authoring two of them is one
144
+ * mistake with one fix, and the build stops on this note either way.
145
+ *
146
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
147
+ * could not be parsed.
148
+ * @param {readonly {key: string, from: string}[]} [keys] - What the compiler
149
+ * derives.
150
+ * @param {object} options - Options.
151
+ * @param {string} options.block - The system block to look in.
152
+ * @param {string} [options.file] - The note's path, named in the message. Omit
153
+ * it where the caller emits through a diagnostic, which puts the locator at
154
+ * the start of the line already.
155
+ * @param {string} [options.absPath] - The note's file on disk, read only on the
156
+ * failing path to locate the offending line and column. The position rides on
157
+ * the thrown error as `position`, for a caller that emits a diagnostic.
158
+ * @returns {void}
159
+ * @throws {Error} When the note authors one.
160
+ */
161
+ export function assertNoDerivedFields(fm, keys, { block, file, absPath } = {}) {
162
+ const [entry] = authoredDerivedKeys(fm, keys, { block });
163
+ if (!entry) return;
164
+
165
+ const wrote = `${block}.${SYSTEM_DATA_KEY}.${entry.key}`;
166
+ const err = new Error(`${derivedMessage(wrote, entry.from, file)}.`);
167
+ // The **leaf** of the key, which is how the note writes it: a locator
168
+ // handed a dotted path would find nothing. Deliberately not anchored at
169
+ // column 1 — the key lives two levels in, under `<block>.system`.
170
+ const leaf = entry.key.split(".").pop();
171
+ const position = locateFrontmatterKey(absPath, /** @type {string} */ (leaf));
172
+ if (position) err.position = position;
173
+ throw err;
174
+ }
@@ -18,8 +18,7 @@
18
18
  * in "The Capital Nome"` — has told the author what is wrong and not where it
19
19
  * is. A name is not an address: finding it means searching the tree for a note
20
20
  * whose `name.full` matches and then searching that file for the link, and
21
- * four identical warnings on one note are indistinguishable from one another
22
- * (#17).
21
+ * four identical warnings on one note are indistinguishable from one another.
23
22
  *
24
23
  * So every diagnostic about a note is emitted in the form every C-family
25
24
  * compiler, `tsc` and ESLint already use:
@@ -185,7 +184,7 @@ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lin
185
184
  * same name answers when no top-level one is present — which is usually what a
186
185
  * reader wants, the key being unique in nearly every note. Pass `topLevel` where
187
186
  * it is not: `aliases` is both a retired top-level field and a **permitted**
188
- * `name.aliases` (#180), and a finding about the first must never open on the
187
+ * `name.aliases`, and a finding about the first must never open on the
189
188
  * second, which would tell an author to delete a field they are allowed to
190
189
  * write.
191
190
  *
@@ -387,7 +386,7 @@ export function yamlKeyPath(field) {
387
386
  * the same name is legal in two regions — `data.weight` and a top-level
388
387
  * `weight` are different keys, and a search finds whichever comes first.
389
388
  *
390
- * So this is the located form for the closed regions (#128). It reuses
389
+ * So this is the located form for the closed regions. It reuses
391
390
  * {@link positionOfYamlPath} rather than repeating its parse, which is what
392
391
  * keeps a finding about `data.weight` pointing at `data.weight`.
393
392
  *