@heroiclands/package-build 17.1.0 → 18.0.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 (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -61,6 +61,28 @@
61
61
  * Foundry data root, so anything left there ships inside the installed system
62
62
  * to every player, and a build artifact has no business there.
63
63
  *
64
+ * **What it deliberately does not carry: the note's text, and positions within
65
+ * it.** #243 asks whether the index should record a position for every
66
+ * frontmatter key, so that a pass reading the index could report a field defect
67
+ * without opening the file. It should not, and the numbers are not close: over
68
+ * `sohl`'s 1,685 notes the index is 3.0 MB and holds 50,598 leaf values, so a
69
+ * `{line, column}` on each would add roughly 1.6 MB — **a 54% larger artifact**
70
+ * — to carry data that is only ever read on the *failing* path.
71
+ *
72
+ * The rule that replaces it is the one this module was already built on:
73
+ * **the index carries what is _about_ a note; the file carries the note's text
74
+ * and every position within it.** Any pass needing either opens the file whose
75
+ * path the record already names ({@link noteFile}). That costs nothing it was
76
+ * not already paying — a check reads each note once for its body, and a
77
+ * compiler must read the prose regardless, so while it holds the bytes a
78
+ * position is free. Recording positions would charge every build, and every
79
+ * reader of the artifact, for something the passes that want them get for
80
+ * nothing.
81
+ *
82
+ * The exception proves the rule: an **anchor** carries its `line`, because an
83
+ * anchor is a fact about the note's structure that a consumer addresses
84
+ * directly, not a locator for a diagnostic about a key.
85
+ *
64
86
  * @module
65
87
  */
66
88
 
@@ -69,107 +91,58 @@ import path from "node:path";
69
91
 
70
92
  import unidecode from "unidecode";
71
93
 
72
- import { addressSlug } from "./content-address.mjs";
73
- import { canonicalKey } from "./kb-manifest.mjs";
74
- import { walkMarkdownTree } from "./helpers.mjs";
75
- import { loadPackConfig } from "./pack-config.mjs";
94
+ import { metadataFileName } from "./metadata-index.mjs";
95
+ import { addressSlug, canonicalKey } from "./content-address.mjs";
96
+ // One reader for a note's anchors, shared with the link checker and with the
97
+ // builds that emit a link (#243). Re-exported because this is where callers
98
+ // have always addressed it.
99
+ import { collectAnchors } from "./anchors.mjs";
100
+ import { subtypeRow, NO_SYSTEM, systemOf } from "./document-subtypes.mjs";
101
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
76
102
 
77
103
  /**
78
- * The keys this module adds to a record, which a note therefore may not carry
79
- * itself.
80
- *
81
- * `package` is the note's distribution unit — the configured `contentPackage`,
82
- * since a note declaring its own is a hard error (package-build#56) — and it
83
- * matches what the content-table expander puts on the same field, so a query
84
- * reads the same value from either. `file` namespaces the note's place in the
85
- * tree, again matching the expander's `file.*`.
104
+ * The `<system>` a note belongs to when it belongs to none.
86
105
  *
87
- * Both are checked rather than assumed: `folder` is real frontmatter on most
88
- * notes, so the neighbouring names are close enough to a real key that a silent
89
- * overwrite is a plausible future rather than a hypothetical one.
106
+ * The specification's word, not this module's: the canonical address carries it
107
+ * in the same position `harnadventures-none-being-grod` so the index and
108
+ * the address say "no system" the same way (#59).
90
109
  *
91
- * @type {ReadonlyArray<string>}
92
- */
93
- export const DERIVED_KEYS = Object.freeze([
94
- "package",
95
- "file",
96
- "address",
97
- "anchors",
98
- "nameAscii",
99
- "aliasesAscii",
100
- ]);
101
-
102
- /**
103
- * A heading, and the `{#slug}` anchor it declares.
104
- *
105
- * Kept identical to the pair {@link splitPages} matches, because the two must
106
- * agree about what an anchor is: that pass decides which sections become
107
- * addressable journal pages, and an index naming an anchor it does not produce
108
- * would advertise a link that resolves nowhere. `tests/content-index.test.ts`
109
- * asserts the two find the same anchors, so drift fails the suite rather than
110
- * shipping.
111
- */
112
- const HEADING = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/;
113
- const ANCHOR = /^(.*?)\s*\{#([^}]+)\}\s*$/;
114
-
115
- /**
116
- * The `{#slug}` anchors a note's body declares, with where each one sits.
117
- *
118
- * Only headings carrying an explicit anchor are collected. A bare `#` heading
119
- * also starts a journal page, but it declares no slug, so nothing can address
120
- * it with `#…` — listing it would offer a link that cannot be written.
121
- *
122
- * @param {string} body - The note's markdown body, frontmatter already removed.
123
- * @param {number} [bodyLine] - The 1-based file line the body starts on, from
124
- * `parseMarkdownFile`. Anchors are reported at their position in the **file**,
125
- * so an editor can jump straight to one; passing nothing numbers from the body.
126
- * @returns {Array<{slug: string, name: string, level: number, line: number}>}
127
- * In document order.
110
+ * @type {string}
128
111
  */
129
- export function collectAnchors(body, bodyLine = 1) {
130
- const anchors = [];
131
- let inCodeBlock = false;
132
- const lines = String(body ?? "").split("\n");
133
112
 
134
- for (let i = 0; i < lines.length; i++) {
135
- // A fenced block's contents are not headings, and `#` is a comment in
136
- // most of what gets fenced.
137
- if (lines[i].trim().startsWith("```")) {
138
- inCodeBlock = !inCodeBlock;
139
- continue;
140
- }
141
- if (inCodeBlock) continue;
113
+ export { collectAnchors };
114
+ import { entriesForNote, foundryIdentities } from "./foundry-entries.mjs";
115
+ import { walkMarkdownTree } from "./helpers.mjs";
116
+ import { resolveNoteId } from "./note-ids.mjs";
117
+ // The retired-field refusal and the key locator, so a note authoring a derived
118
+ // key is reported where it is rather than as a bare abort (#243).
119
+ import { assertNoDeclaredPackage } from "./note-package.mjs";
120
+ import { locateFrontmatterKey } from "./retired-fields.mjs";
121
+ import { loadPackConfig } from "./pack-config.mjs";
122
+ // The record accessors, which live apart so that a module the compilers load
123
+ // can read a record without importing this one and closing a cycle (#243).
124
+ // Re-exported because this is where callers have always addressed them.
125
+ import { authoredFrontmatter, DERIVED_KEYS, isNoteRecord, noteFile } from "./index-records.mjs";
142
126
 
143
- const heading = HEADING.exec(lines[i]);
144
- if (!heading) continue;
145
- const anchor = ANCHOR.exec(heading[2].trim());
146
- if (!anchor) continue;
147
-
148
- const slug = anchor[2].trim();
149
- if (!slug) continue;
150
- anchors.push({
151
- slug,
152
- name: anchor[1].trim(),
153
- level: heading[1].length,
154
- line: bodyLine + i,
155
- });
156
- }
157
- return anchors;
158
- }
127
+ export { authoredFrontmatter, DERIVED_KEYS, isNoteRecord, noteFile };
159
128
 
160
129
  /**
161
130
  * The address a wikilink writes to reach a note, or `null` when it has none.
162
131
  *
163
132
  * A wikilink target is an address: `being-aurochs` locally, or
164
133
  * `sohl-being-aurochs` from another package (`readQualifier` also accepts
165
- * `being/aurochs`, the same two fields with a different separator). Both forms
166
- * are already derivable from `type` and `shortcode`, which every record
167
- * carries so this field adds no information. What it adds is the *rule*:
168
- * the lowercasing and the hyphen join live in one place, and a consumer that
169
- * reimplements them slightly differently gets a lookup that matches nothing and
170
- * says nothing about why. That is a real failure, not a hypothetical one it
171
- * is precisely how a resolver keyed on a bare `type/shortcode` silently misses
172
- * every canonical `pkg-type-shortcode` entry.
134
+ * `being/aurochs`, the same two fields with a different separator). Both are
135
+ * *partial* addresses the canonical one this field records is
136
+ * `sohl-sohl-being-aurochs`, and a target that omits the system is matched
137
+ * with that segment wildcarded. Every form is already derivable from `type` and
138
+ * `shortcode`, which every record carries, plus the system the type compiles
139
+ * into so this field adds no information. What it adds is the *rule*: the
140
+ * lowercasing, the hyphen join and the system lookup live in one place, and a
141
+ * consumer that reimplements them slightly differently gets a lookup that
142
+ * matches nothing and says nothing about why. That is a real failure, not a
143
+ * hypothetical one — it is precisely how a resolver keyed on a bare
144
+ * `type/shortcode` silently misses every canonical
145
+ * `pkg-system-type-shortcode` entry.
173
146
  *
174
147
  * Derived by the same functions the link manifest and the site build use, so an
175
148
  * index cannot disagree with either about where a note lives.
@@ -177,8 +150,8 @@ export function collectAnchors(body, bodyLine = 1) {
177
150
  * @param {Record<string, any>} frontmatter - The note's parsed frontmatter.
178
151
  * @param {string} contentPackage - The package the tree compiles as.
179
152
  * @returns {{slug: string, canonical: string}|null} `slug` is what goes inside
180
- * `[[…]]` within this package; `canonical` is the package-qualified key the
181
- * manifest files the note under. `null` for a note with no type or no
153
+ * `[[…]]` within this package; `canonical` is the fully qualified key the
154
+ * manifest files the note under, carrying the package and the system as well. `null` for a note with no type or no
182
155
  * shortcode, which has no address at all and is stated as such rather than
183
156
  * left for every reader to rediscover.
184
157
  */
@@ -194,7 +167,12 @@ export function noteAddress(frontmatter, contentPackage) {
194
167
  }
195
168
  return {
196
169
  slug,
197
- canonical: canonicalKey(contentPackage, frontmatter.type, frontmatter.shortcode),
170
+ canonical: canonicalKey(
171
+ contentPackage,
172
+ systemOf(frontmatter.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
173
+ frontmatter.type,
174
+ frontmatter.shortcode,
175
+ ),
198
176
  };
199
177
  }
200
178
 
@@ -294,19 +272,159 @@ export function asciiAliases(aliases) {
294
272
  * @throws {Error} When the note carries a key this module derives, which would
295
273
  * otherwise be overwritten without a word.
296
274
  */
297
- export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, bodyLine }) {
275
+ /**
276
+ * This note's Foundry addresses, or `null` where it has none.
277
+ *
278
+ * **Derived by the manifest's own code, not a second implementation of it.**
279
+ * {@link module:engine/manifest-emit.entriesForNote} is what the link manifest
280
+ * emits from, and a UUID is a function of the note's `type` and authored `id`
281
+ * plus the pack router — frontmatter and configuration, nothing from a compiled
282
+ * pack — so the index's frontmatter walk already has every input. Deriving it
283
+ * twice is how two artifacts describing one note start disagreeing, which is
284
+ * the failure the merge is meant to end (#239).
285
+ *
286
+ * The shape flattens the manifest's *two* entries for an item note onto the one
287
+ * record the index keeps per note. An item compiles into a document **and** a
288
+ * documentation journal, and both are addressable — so the item's own UUID sits
289
+ * at the top and the journal's beside it under `doc`, with the anchor map that
290
+ * addresses its pages. A note that is itself a journal carries that map
291
+ * directly.
292
+ *
293
+ * Every address is independently optional, exactly as the manifest has it: a
294
+ * note that compiles to no document has no UUID, and inventing one would assert
295
+ * a target that does not exist.
296
+ *
297
+ * @param {object} args - Arguments.
298
+ * @param {object} args.frontmatter - The note's frontmatter.
299
+ * @param {object|null} args.address - Its resolved address, or null.
300
+ * @param {string} args.body - The note body, for anchor discovery.
301
+ * @param {object|null} args.manifest - The manifest context, when available.
302
+ * @returns {object|null} `{ uuid?, anchors?, doc? }`, or null when the note has
303
+ * no Foundry address at all.
304
+ */
305
+ function foundryEntries({ frontmatter, address, body, manifest }) {
306
+ // No address is not an error here — the index records every note, including
307
+ // ones that publish nothing, and the manifest reports that case separately.
308
+ if (!manifest || !address) return null;
309
+
310
+ let entries;
311
+ try {
312
+ // The slug, not the address object: the manifest emitter takes the
313
+ // published path as a string and builds its `url` from it.
314
+ entries = entriesForNote(
315
+ frontmatter,
316
+ frontmatter?.name?.full ?? "",
317
+ address.slug,
318
+ body ?? "",
319
+ manifest,
320
+ );
321
+ } catch {
322
+ // A note the manifest cannot address is still a note. The index says so
323
+ // by carrying no `foundry` block rather than by failing the walk.
324
+ return null;
325
+ }
326
+ if (!entries?.length) return null;
327
+ const [own, docEntry] = entries;
328
+ return { own: own ?? null, doc: docEntry ?? null };
329
+ }
330
+
331
+ /**
332
+ * The `foundry` block for one manifest entry, **keyed by the system that
333
+ * compiles it**.
334
+ *
335
+ * A note may declare more than one system — 2,497 of `harn-ensemble`'s carry
336
+ * both a `sohl:` and an `hm3:` block — and each compiles into its *own* Foundry
337
+ * document, of that system's document type, in that system's pack. One `uuid`
338
+ * on the record cannot name two documents, so it named whichever the single
339
+ * shipped map produced and said nothing about the other.
340
+ *
341
+ * Only `sohl` can appear today, because `KNOWN_DOCUMENT_SUBTYPE_MAPS` holds one
342
+ * map and #139 tracks the missing `hm3/` half. The shape is system-keyed now so
343
+ * that adding it is one more key rather than a second breaking change to an
344
+ * artifact consumers have already started reading.
345
+ *
346
+ * **Derived, and a sibling of the authored block rather than inside it.** The
347
+ * uuid could have been synthesized onto `sohl:`/`hm3:` themselves, but those are
348
+ * regions a note *authors*, and {@link DERIVED_KEYS} — which refuses a note that
349
+ * writes over derived data — reaches only the top level. A note authoring
350
+ * `sohl.uuid` would collide silently, which is the failure this index exists to
351
+ * stop rather than to add.
352
+ *
353
+ * @param {object|null} entry - A manifest entry.
354
+ * @param {string} system - The system whose document this is.
355
+ * @returns {object|null} `{ [system]: { uuid?, anchors? } }`, or null when it
356
+ * addresses nothing.
357
+ */
358
+
359
+ function foundryBlock(entry, system) {
360
+ if (!entry) return null;
361
+ const block = {};
362
+ if (entry.uuid) block.uuid = entry.uuid;
363
+ if (entry.anchors) block.anchors = entry.anchors;
364
+ if (!Object.keys(block).length) return null;
365
+ return { [system || NO_SYSTEM]: block };
366
+ }
367
+
368
+ /**
369
+ * Refuse a note that authors a key the index derives, and say where.
370
+ *
371
+ * **Located, because every reader of the index is now a reporter of this.**
372
+ * Until #243 the only pass that built a record was the emitter, so aborting
373
+ * with a bare message was the whole story. Now the link check and the address
374
+ * diff read the index too, and a bare abort in one of them reports *nothing*
375
+ * about the tree — the one malformed note takes every other finding with it,
376
+ * and the reader is handed a stack instead of a line to open. So the error
377
+ * carries `file` and a `position`, and a pass that collects rather than throws
378
+ * can emit `file:line:column: error: …` like any other finding.
379
+ *
380
+ * **`package:` keeps its own words.** It is not a name collision but a *retired
381
+ * field* (#56), and the correction is to delete it, not to rename it — which is
382
+ * what {@link module:engine/note-package.assertNoDeclaredPackage} has always
383
+ * said, and had no caller to say it to. Deferring to it means one message for
384
+ * one mistake rather than two that contradict each other about the fix.
385
+ *
386
+ * @param {object} frontmatter - The note's parsed frontmatter.
387
+ * @param {string} relPath - The note's path within the tree, for the message.
388
+ * @param {string} [absPath] - The file, read only on the failing path to locate
389
+ * the offending key.
390
+ * @param {string} [contentPackage] - The package this tree compiles as.
391
+ * @returns {void}
392
+ * @throws {Error} When the note authors a derived key. `file` and `position`
393
+ * ride on the error.
394
+ */
395
+ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
298
396
  for (const key of DERIVED_KEYS) {
299
- if (Object.hasOwn(frontmatter ?? {}, key)) {
300
- throw new Error(
301
- `${relPath}: \`${key}:\` is derived by the content index and ` +
302
- `cannot be authored rename the frontmatter field`,
303
- );
397
+ if (!Object.hasOwn(frontmatter ?? {}, key)) continue;
398
+ if (key === "package") {
399
+ // Throws with its own wording, and its own position.
400
+ assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage });
304
401
  }
402
+ const err = new Error(
403
+ `\`${key}:\` is derived by the content index and cannot be ` +
404
+ `authored — rename the frontmatter field`,
405
+ );
406
+ err.file = relPath;
407
+ const position = absPath ? locateFrontmatterKey(absPath, key) : undefined;
408
+ if (position) err.position = position;
409
+ throw err;
305
410
  }
411
+ }
412
+
413
+ export function buildIndexRecord({
414
+ frontmatter,
415
+ relPath,
416
+ absPath,
417
+ contentPackage,
418
+ body,
419
+ bodyLine,
420
+ manifest,
421
+ }) {
422
+ assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
306
423
 
307
424
  const posix = relPath.split(path.sep).join("/");
308
425
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
309
426
  const address = noteAddress(frontmatter, contentPackage);
427
+ const entries = foundryEntries({ frontmatter, address, body, manifest });
310
428
 
311
429
  return /** @type {Record<string, any>} */ (
312
430
  sortKeysDeep({
@@ -323,6 +441,14 @@ export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, b
323
441
  ...a,
324
442
  link: address ? `${address.slug}#${a.slug}` : null,
325
443
  })),
444
+ foundry: foundryBlock(
445
+ entries?.own,
446
+ systemOf(frontmatter?.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
447
+ ),
448
+ // Forward link to the note's documentation journal, which is its
449
+ // own record. Named rather than nested, because the journal is a
450
+ // separate document with its own address — see `buildDocRecord`.
451
+ documentation: entries?.doc?.key ?? null,
326
452
  file: {
327
453
  // Relative to the content root, and deliberately not absolute.
328
454
  // An absolute path is a fact about the machine that built the
@@ -350,31 +476,141 @@ export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, b
350
476
  * @returns {Array<Record<string, any>>} The records, in a total order that does
351
477
  * not depend on directory-read order.
352
478
  */
353
- export function collectContentIndex(contentBase, { contentPackage, skipDirectories }) {
479
+ /**
480
+ * The record for an item note's **documentation journal**.
481
+ *
482
+ * An item note compiles into two documents — the item, and a JournalEntry
483
+ * holding its prose — and the second is a document in its own right: its own
484
+ * canonical address (`doc<type>/<shortcode>`), its own UUID, its own pages.
485
+ * So it gets its own record, and resolving `docaffliction/blkdth` is the same
486
+ * lookup as resolving anything else. Nested inside the item's record it would
487
+ * be the one address in the index reachable only by knowing to look somewhere
488
+ * else, which every consumer would have to special-case.
489
+ *
490
+ * **Lean, and deliberately not the note's frontmatter.** The item's `sohl:`
491
+ * block describes the *item*; copying it onto the journal would assert things
492
+ * about the journal that are not true, and double the file to do it. What the
493
+ * journal has of its own is its addresses, its name, and the file it came from
494
+ * — plus `documents`, naming the record it is the documentation for, so the
495
+ * link is navigable in both directions.
496
+ *
497
+ * On the web both addresses resolve to one page — the item note renders as the
498
+ * page that *is* its documentation — so the slug is shared and only the
499
+ * canonical key differs.
500
+ *
501
+ * @param {object} args - Arguments.
502
+ * @param {object} args.frontmatter - The item note's frontmatter.
503
+ * @param {object} args.address - The item's own address.
504
+ * @param {object} args.entry - The manifest's doc entry.
505
+ * @param {object} args.file - The `file` block of the item's record.
506
+ * @param {string} args.contentPackage - The package the note belongs to.
507
+ * @param {Array<object>} args.anchors - The web anchors of the note body.
508
+ * @returns {Record<string, any>} The documentation journal's index record.
509
+ */
510
+ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anchors }) {
511
+ return /** @type {Record<string, any>} */ (
512
+ sortKeysDeep({
513
+ package: contentPackage,
514
+ type: `doc${frontmatter.type}`,
515
+ shortcode: frontmatter.shortcode,
516
+ name: frontmatter.name,
517
+ nameAscii: asciiName(frontmatter?.name?.full),
518
+ address: { slug: address.slug, canonical: entry.key },
519
+ // The record this is the documentation *for*. `documentation` is
520
+ // the forward link on that record, so either end reaches the other.
521
+ documents: address.canonical,
522
+ anchors,
523
+ foundry: foundryBlock(entry), // a journal: no system key
524
+ file,
525
+ })
526
+ );
527
+ }
528
+
529
+ export function collectContentIndex(
530
+ contentBase,
531
+ { contentPackage, skipDirectories, manifest, problems },
532
+ ) {
354
533
  const records = [];
355
- const walkOpts = skipDirectories ? { skipDirectories } : {};
534
+ // Passed through rather than defaulted away: an absent scope is the
535
+ // caller's omission, and `walkMarkdownTree` says so (#243).
536
+ const walkOpts = { skipDirectories };
356
537
 
357
538
  for (const { frontmatter, body, bodyLine, absPath } of walkMarkdownTree(
358
539
  contentBase,
359
540
  walkOpts,
360
541
  )) {
361
- records.push(
362
- buildIndexRecord({
363
- frontmatter: frontmatter ?? {},
364
- relPath: path.relative(contentBase, absPath),
542
+ const fm = frontmatter ?? {};
543
+ // The id the note's document is filed under (#270), resolved before
544
+ // the record is built so the index publishes the address *and* the id
545
+ // that address derives.
546
+ resolveNoteId(fm, { pkg: contentPackage });
547
+ const relPath = path.relative(contentBase, absPath);
548
+ let record;
549
+ try {
550
+ record = buildIndexRecord({
551
+ frontmatter: fm,
552
+ relPath,
553
+ absPath,
365
554
  contentPackage,
366
555
  body,
367
556
  bodyLine,
368
- }),
369
- );
557
+ manifest,
558
+ });
559
+ } catch (err) {
560
+ // No `problems` array means the caller wants the old contract: a
561
+ // note that cannot be recorded fails the derivation outright, which
562
+ // is right for the *emitter* — an index quietly missing a note
563
+ // would state that the note does not exist.
564
+ if (!problems) throw err;
565
+ // A reader, by contrast, reports it and carries on: one malformed
566
+ // note must not take every other finding in the tree with it.
567
+ problems.push({
568
+ file: absPath,
569
+ ...(err.position ?? {}),
570
+ severity: "error",
571
+ message: String(err.message),
572
+ });
573
+ continue;
574
+ }
575
+ records.push(record);
576
+
577
+ // An item note is two documents, so it is two records (#239).
578
+ const doc = foundryEntries({
579
+ frontmatter: fm,
580
+ address: record.address,
581
+ body,
582
+ manifest,
583
+ })?.doc;
584
+ if (doc?.key && record.address) {
585
+ records.push(
586
+ buildDocRecord({
587
+ frontmatter: fm,
588
+ address: record.address,
589
+ entry: doc,
590
+ file: record.file,
591
+ contentPackage,
592
+ anchors: record.anchors,
593
+ }),
594
+ );
595
+ }
370
596
  }
371
597
 
372
598
  // Content path, then the note id. The walk yields in directory-read order,
373
599
  // which is not a fact about the content, and a rebuild that reorders lines
374
600
  // would make every regeneration look like a change.
601
+ // Content path, then the canonical address, then the note id. The walk
602
+ // yields in directory-read order, which is not a fact about the content,
603
+ // and a rebuild that reordered lines would make every regeneration look
604
+ // like a change. The address comes before the id because an item note's two
605
+ // records share a file and only one of them carries an id — ordering on the
606
+ // id first would put the documentation ahead of the item it documents.
375
607
  records.sort(
376
608
  (a, b) =>
377
609
  String(a.file.path).localeCompare(String(b.file.path), "en") ||
610
+ String(a.address?.canonical ?? "").localeCompare(
611
+ String(b.address?.canonical ?? ""),
612
+ "en",
613
+ ) ||
378
614
  String(a.id ?? "").localeCompare(String(b.id ?? ""), "en"),
379
615
  );
380
616
  return records;
@@ -393,6 +629,43 @@ export function serializeContentIndex(records) {
393
629
  return `${records.map((r) => JSON.stringify(r)).join("\n")}\n`;
394
630
  }
395
631
 
632
+ /**
633
+ * The index records for a content tree, without writing anything.
634
+ *
635
+ * The half of {@link emitContentIndex} that derives rather than emits, so a
636
+ * pass that needs the corpus in memory — a SQL content table, the link check,
637
+ * and in time every reader #243 converts — builds it the same way the artifact
638
+ * is built, rather than by walking and parsing again with its own idea of the
639
+ * scope.
640
+ *
641
+ * @param {object} [opts]
642
+ * @param {string} [opts.contentBase] - The tree, defaulting to the configured one.
643
+ * @param {object} [opts.config] - Resolved configuration, defaulting to ambient.
644
+ * @param {readonly string[]} [opts.skipDirectories] - The walk's scope, for a
645
+ * caller that resolved one of its own; defaults to the resolved
646
+ * configuration's. Stated separately from `config` because a caller that was
647
+ * *handed* a scope must be able to pass it on rather than have it silently
648
+ * replaced by the one its configuration happens to carry (#243).
649
+ * @param {object[]} [opts.problems] - Supplied by a **reader**: a note that
650
+ * cannot be recorded is pushed here as a diagnostic and skipped, instead of
651
+ * aborting the derivation. Omitted, the note throws — which is the contract
652
+ * the emitter needs, since an index missing a note asserts that it does not
653
+ * exist.
654
+ * @returns {object[]} One record per note, plus one per documentation entry.
655
+ */
656
+ export function indexRecordsFor({ contentBase, config, skipDirectories, problems } = {}) {
657
+ const resolved = config ?? loadPackConfig();
658
+ const tree = contentBase ?? resolved.paths.content;
659
+ if (!fs.existsSync(tree)) throw new Error(`no content tree at ${tree}`);
660
+ return collectContentIndex(tree, {
661
+ contentPackage: resolved.contentPackage,
662
+ skipDirectories: skipDirectories ?? resolved.skipDirectories,
663
+ // Only the identities a UUID is a function of — see emitContentIndex.
664
+ manifest: foundryIdentities(resolved),
665
+ problems,
666
+ });
667
+ }
668
+
396
669
  /**
397
670
  * Emit this package's content index.
398
671
  *
@@ -419,10 +692,16 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
419
692
  throw new Error(`no content tree at ${tree}`);
420
693
  }
421
694
 
422
- const records = collectContentIndex(tree, {
423
- contentPackage,
424
- skipDirectories: resolved.skipDirectories,
425
- });
695
+ // The identities a Foundry address is derived against. Resolved once and
696
+ // passed down, the way the manifest emission does it, so the walk stays a
697
+ // pure function of its context. A configuration that names no Foundry
698
+ // package yields a context whose notes simply carry no UUID.
699
+ // Only the identities a UUID is a function of — the package id and the pack
700
+ // router. Deliberately not the manifest's full context: whether a package
701
+ // publishes pages is no part of an address, and depending on it would make
702
+ // the index refuse to build for a configuration that is perfectly able to
703
+ // state one.
704
+ const records = indexRecordsFor({ contentBase: tree, config: resolved });
426
705
  if (records.length === 0) {
427
706
  throw new Error(
428
707
  `${tree} yielded no notes, so the index would state that this ` +
@@ -431,9 +710,13 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
431
710
  }
432
711
 
433
712
  const text = serializeContentIndex(records);
434
- const file = path.join(dir, `${contentPackage}.jsonl`);
713
+ const file = path.join(dir, metadataFileName(contentPackage));
435
714
  fs.mkdirSync(dir, { recursive: true });
436
715
  fs.writeFileSync(file, text);
437
716
 
438
- return { file, notes: records.length, bytes: Buffer.byteLength(text) };
717
+ // Counted separately because they are genuinely different numbers: an item
718
+ // note yields a second record for its documentation journal, so reporting
719
+ // records as notes would overstate how large the tree is.
720
+ const notes = records.filter((r) => !r.documents).length;
721
+ return { file, notes, records: records.length, bytes: Buffer.byteLength(text) };
439
722
  }