@heroiclands/package-build 17.2.0 → 18.1.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 (151) hide show
  1. package/CHANGELOG.md +1741 -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 -60
  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 +225 -127
  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} +55 -83
  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 +146 -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 +107 -24
  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/manifest.mjs +43 -2
  74. package/package.json +18 -4
  75. package/release.mjs +62 -7
  76. package/sohl/actors.mjs +33 -487
  77. package/sohl/being-info.mjs +13 -4
  78. package/sohl/default-item-art.mjs +14 -3
  79. package/sohl/document-subtypes.mjs +13 -7
  80. package/sohl/item-builders.mjs +14 -5
  81. package/sohl/item-fields.mjs +65 -4
  82. package/sohl/items.mjs +44 -258
  83. package/types/content-config.d.mts +21 -42
  84. package/types/engine/actor-compiler.d.mts +204 -0
  85. package/types/engine/address-charset.d.mts +11 -8
  86. package/types/engine/address-diff.d.mts +53 -5
  87. package/types/engine/anchored-sections.d.mts +21 -0
  88. package/types/engine/anchors.d.mts +20 -0
  89. package/types/engine/base-compiler.d.mts +17 -17
  90. package/types/engine/bundle-notes.d.mts +173 -0
  91. package/types/engine/bundles.d.mts +60 -0
  92. package/types/engine/code-fences.d.mts +43 -0
  93. package/types/engine/compile-corpus.d.mts +32 -0
  94. package/types/engine/content-address.d.mts +205 -5
  95. package/types/engine/content-format-check.d.mts +6 -2
  96. package/types/engine/content-format.d.mts +57 -1
  97. package/types/engine/content-index.d.mts +54 -49
  98. package/types/engine/content-links.d.mts +52 -7
  99. package/types/engine/content-lint.d.mts +10 -1
  100. package/types/engine/content-package.d.mts +2 -1
  101. package/types/engine/content-tables.d.mts +21 -39
  102. package/types/engine/document-subtypes.d.mts +37 -3
  103. package/types/engine/field-spec.d.mts +76 -5
  104. package/types/engine/folder-notes.d.mts +159 -0
  105. package/types/engine/foreign-catalog.d.mts +53 -0
  106. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +2 -43
  107. package/types/engine/frontmatter-lint.d.mts +10 -2
  108. package/types/engine/frontmatter.d.mts +64 -0
  109. package/types/engine/generate.d.mts +38 -0
  110. package/types/engine/helpers.d.mts +94 -30
  111. package/types/engine/ids.d.mts +105 -0
  112. package/types/engine/index-records.d.mts +68 -0
  113. package/types/engine/index.d.mts +9 -3
  114. package/types/engine/item-compiler.d.mts +131 -0
  115. package/types/engine/journals.d.mts +47 -9
  116. package/types/engine/metadata-index.d.mts +226 -0
  117. package/types/engine/note-claims.d.mts +57 -11
  118. package/types/engine/note-ids.d.mts +38 -0
  119. package/types/engine/note-renames.d.mts +102 -0
  120. package/types/engine/note-vocabulary.d.mts +41 -5
  121. package/types/engine/retired-fields.d.mts +75 -0
  122. package/types/engine/scenes.d.mts +3 -2
  123. package/types/engine/schema-check.d.mts +25 -4
  124. package/types/engine/site-build.d.mts +4 -4
  125. package/types/engine/site-index.d.mts +1 -1
  126. package/types/engine/sql-tables.d.mts +185 -0
  127. package/types/engine/subtype-registry.d.mts +49 -0
  128. package/types/engine/system-block.d.mts +40 -1
  129. package/types/engine/systems.d.mts +106 -0
  130. package/types/engine/web-wikilinks.d.mts +4 -2
  131. package/types/engine/wikilink-syntax.d.mts +10 -3
  132. package/types/engine/wikilinks.d.mts +41 -13
  133. package/types/engine/yaml-lint.d.mts +107 -0
  134. package/types/hm3/actors.d.mts +48 -0
  135. package/types/hm3/default-item-art.d.mts +42 -0
  136. package/types/hm3/document-subtypes.d.mts +24 -0
  137. package/types/hm3/index.d.mts +7 -0
  138. package/types/hm3/item-builders.d.mts +11 -0
  139. package/types/hm3/item-fields.d.mts +12 -0
  140. package/types/hm3/items.d.mts +23 -0
  141. package/types/hm3/template-priority.d.mts +21 -0
  142. package/types/manifest.d.mts +22 -1
  143. package/types/release.d.mts +9 -4
  144. package/types/sohl/actors.d.mts +7 -74
  145. package/types/sohl/being-info.d.mts +8 -27
  146. package/types/sohl/default-item-art.d.mts +5 -3
  147. package/types/sohl/items.d.mts +17 -32
  148. package/engine/foreign-manifests.mjs +0 -126
  149. package/engine/kb-manifest.mjs +0 -490
  150. package/types/engine/foreign-manifests.d.mts +0 -43
  151. 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,111 +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 { entriesForNote, foundryIdentities } from "./manifest-emit.mjs";
75
- import { walkMarkdownTree } from "./helpers.mjs";
76
- 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";
77
102
 
78
103
  /**
79
- * The keys this module adds to a record, which a note therefore may not carry
80
- * itself.
104
+ * The `<system>` a note belongs to when it belongs to none.
81
105
  *
82
- * `package` is the note's distribution unit the configured `contentPackage`,
83
- * since a note declaring its own is a hard error (package-build#56) — and it
84
- * matches what the content-table expander puts on the same field, so a query
85
- * reads the same value from either. `file` namespaces the note's place in the
86
- * tree, again matching the expander's `file.*`.
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).
87
109
  *
88
- * Both are checked rather than assumed: `folder` is real frontmatter on most
89
- * notes, so the neighbouring names are close enough to a real key that a silent
90
- * overwrite is a plausible future rather than a hypothetical one.
91
- *
92
- * @type {ReadonlyArray<string>}
93
- */
94
- export const DERIVED_KEYS = Object.freeze([
95
- "package",
96
- "file",
97
- "address",
98
- "anchors",
99
- "nameAscii",
100
- "aliasesAscii",
101
- "foundry",
102
- "documentation",
103
- "documents",
104
- ]);
105
-
106
- /**
107
- * A heading, and the `{#slug}` anchor it declares.
108
- *
109
- * Kept identical to the pair {@link splitPages} matches, because the two must
110
- * agree about what an anchor is: that pass decides which sections become
111
- * addressable journal pages, and an index naming an anchor it does not produce
112
- * would advertise a link that resolves nowhere. `tests/content-index.test.ts`
113
- * asserts the two find the same anchors, so drift fails the suite rather than
114
- * shipping.
115
- */
116
- const HEADING = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/;
117
- const ANCHOR = /^(.*?)\s*\{#([^}]+)\}\s*$/;
118
-
119
- /**
120
- * The `{#slug}` anchors a note's body declares, with where each one sits.
121
- *
122
- * Only headings carrying an explicit anchor are collected. A bare `#` heading
123
- * also starts a journal page, but it declares no slug, so nothing can address
124
- * it with `#…` — listing it would offer a link that cannot be written.
125
- *
126
- * @param {string} body - The note's markdown body, frontmatter already removed.
127
- * @param {number} [bodyLine] - The 1-based file line the body starts on, from
128
- * `parseMarkdownFile`. Anchors are reported at their position in the **file**,
129
- * so an editor can jump straight to one; passing nothing numbers from the body.
130
- * @returns {Array<{slug: string, name: string, level: number, line: number}>}
131
- * In document order.
110
+ * @type {string}
132
111
  */
133
- export function collectAnchors(body, bodyLine = 1) {
134
- const anchors = [];
135
- let inCodeBlock = false;
136
- const lines = String(body ?? "").split("\n");
137
112
 
138
- for (let i = 0; i < lines.length; i++) {
139
- // A fenced block's contents are not headings, and `#` is a comment in
140
- // most of what gets fenced.
141
- if (lines[i].trim().startsWith("```")) {
142
- inCodeBlock = !inCodeBlock;
143
- continue;
144
- }
145
- if (inCodeBlock) continue;
146
-
147
- const heading = HEADING.exec(lines[i]);
148
- if (!heading) continue;
149
- const anchor = ANCHOR.exec(heading[2].trim());
150
- if (!anchor) 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";
151
126
 
152
- const slug = anchor[2].trim();
153
- if (!slug) continue;
154
- anchors.push({
155
- slug,
156
- name: anchor[1].trim(),
157
- level: heading[1].length,
158
- line: bodyLine + i,
159
- });
160
- }
161
- return anchors;
162
- }
127
+ export { authoredFrontmatter, DERIVED_KEYS, isNoteRecord, noteFile };
163
128
 
164
129
  /**
165
130
  * The address a wikilink writes to reach a note, or `null` when it has none.
166
131
  *
167
132
  * A wikilink target is an address: `being-aurochs` locally, or
168
133
  * `sohl-being-aurochs` from another package (`readQualifier` also accepts
169
- * `being/aurochs`, the same two fields with a different separator). Both forms
170
- * are already derivable from `type` and `shortcode`, which every record
171
- * carries so this field adds no information. What it adds is the *rule*:
172
- * the lowercasing and the hyphen join live in one place, and a consumer that
173
- * reimplements them slightly differently gets a lookup that matches nothing and
174
- * says nothing about why. That is a real failure, not a hypothetical one it
175
- * is precisely how a resolver keyed on a bare `type/shortcode` silently misses
176
- * 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.
177
146
  *
178
147
  * Derived by the same functions the link manifest and the site build use, so an
179
148
  * index cannot disagree with either about where a note lives.
@@ -181,8 +150,8 @@ export function collectAnchors(body, bodyLine = 1) {
181
150
  * @param {Record<string, any>} frontmatter - The note's parsed frontmatter.
182
151
  * @param {string} contentPackage - The package the tree compiles as.
183
152
  * @returns {{slug: string, canonical: string}|null} `slug` is what goes inside
184
- * `[[…]]` within this package; `canonical` is the package-qualified key the
185
- * 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
186
155
  * shortcode, which has no address at all and is stated as such rather than
187
156
  * left for every reader to rediscover.
188
157
  */
@@ -198,7 +167,12 @@ export function noteAddress(frontmatter, contentPackage) {
198
167
  }
199
168
  return {
200
169
  slug,
201
- 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
+ ),
202
176
  };
203
177
  }
204
178
 
@@ -355,35 +329,97 @@ function foundryEntries({ frontmatter, address, body, manifest }) {
355
329
  }
356
330
 
357
331
  /**
358
- * The `foundry` block for one manifest entry.
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.
359
352
  *
360
353
  * @param {object|null} entry - A manifest entry.
361
- * @returns {object|null} `{ uuid?, anchors? }`, or null when it addresses nothing.
354
+ * @param {string} system - The system whose document this is.
355
+ * @returns {object|null} `{ [system]: { uuid?, anchors? } }`, or null when it
356
+ * addresses nothing.
362
357
  */
363
- function foundryBlock(entry) {
358
+
359
+ function foundryBlock(entry, system) {
364
360
  if (!entry) return null;
365
361
  const block = {};
366
362
  if (entry.uuid) block.uuid = entry.uuid;
367
363
  if (entry.anchors) block.anchors = entry.anchors;
368
- return Object.keys(block).length ? block : null;
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) {
396
+ for (const key of DERIVED_KEYS) {
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 });
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;
410
+ }
369
411
  }
370
412
 
371
413
  export function buildIndexRecord({
372
414
  frontmatter,
373
415
  relPath,
416
+ absPath,
374
417
  contentPackage,
375
418
  body,
376
419
  bodyLine,
377
420
  manifest,
378
421
  }) {
379
- for (const key of DERIVED_KEYS) {
380
- if (Object.hasOwn(frontmatter ?? {}, key)) {
381
- throw new Error(
382
- `${relPath}: \`${key}:\` is derived by the content index and ` +
383
- `cannot be authored — rename the frontmatter field`,
384
- );
385
- }
386
- }
422
+ assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
387
423
 
388
424
  const posix = relPath.split(path.sep).join("/");
389
425
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
@@ -405,7 +441,10 @@ export function buildIndexRecord({
405
441
  ...a,
406
442
  link: address ? `${address.slug}#${a.slug}` : null,
407
443
  })),
408
- foundry: foundryBlock(entries?.own),
444
+ foundry: foundryBlock(
445
+ entries?.own,
446
+ systemOf(frontmatter?.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
447
+ ),
409
448
  // Forward link to the note's documentation journal, which is its
410
449
  // own record. Named rather than nested, because the journal is a
411
450
  // separate document with its own address — see `buildDocRecord`.
@@ -481,30 +520,58 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
481
520
  // the forward link on that record, so either end reaches the other.
482
521
  documents: address.canonical,
483
522
  anchors,
484
- foundry: foundryBlock(entry),
523
+ foundry: foundryBlock(entry), // a journal: no system key
485
524
  file,
486
525
  })
487
526
  );
488
527
  }
489
528
 
490
- export function collectContentIndex(contentBase, { contentPackage, skipDirectories, manifest }) {
529
+ export function collectContentIndex(
530
+ contentBase,
531
+ { contentPackage, skipDirectories, manifest, problems },
532
+ ) {
491
533
  const records = [];
492
- 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 };
493
537
 
494
538
  for (const { frontmatter, body, bodyLine, absPath } of walkMarkdownTree(
495
539
  contentBase,
496
540
  walkOpts,
497
541
  )) {
498
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 });
499
547
  const relPath = path.relative(contentBase, absPath);
500
- const record = buildIndexRecord({
501
- frontmatter: fm,
502
- relPath,
503
- contentPackage,
504
- body,
505
- bodyLine,
506
- manifest,
507
- });
548
+ let record;
549
+ try {
550
+ record = buildIndexRecord({
551
+ frontmatter: fm,
552
+ relPath,
553
+ absPath,
554
+ contentPackage,
555
+ body,
556
+ bodyLine,
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
+ }
508
575
  records.push(record);
509
576
 
510
577
  // An item note is two documents, so it is two records (#239).
@@ -562,6 +629,43 @@ export function serializeContentIndex(records) {
562
629
  return `${records.map((r) => JSON.stringify(r)).join("\n")}\n`;
563
630
  }
564
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
+
565
669
  /**
566
670
  * Emit this package's content index.
567
671
  *
@@ -597,13 +701,7 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
597
701
  // publishes pages is no part of an address, and depending on it would make
598
702
  // the index refuse to build for a configuration that is perfectly able to
599
703
  // state one.
600
- const manifest = foundryIdentities(resolved);
601
-
602
- const records = collectContentIndex(tree, {
603
- contentPackage,
604
- skipDirectories: resolved.skipDirectories,
605
- manifest,
606
- });
704
+ const records = indexRecordsFor({ contentBase: tree, config: resolved });
607
705
  if (records.length === 0) {
608
706
  throw new Error(
609
707
  `${tree} yielded no notes, so the index would state that this ` +
@@ -612,7 +710,7 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
612
710
  }
613
711
 
614
712
  const text = serializeContentIndex(records);
615
- const file = path.join(dir, `${contentPackage}.jsonl`);
713
+ const file = path.join(dir, metadataFileName(contentPackage));
616
714
  fs.mkdirSync(dir, { recursive: true });
617
715
  fs.writeFileSync(file, text);
618
716