@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
@@ -37,7 +37,13 @@ import { loadPackConfig } from "./pack-config.mjs";
37
37
  import { packRouter } from "./pack-router.mjs";
38
38
  import { contentPackage, foundryPackageId } from "./content-package.mjs";
39
39
  import { searchableFrontmatter } from "./note-package.mjs";
40
- import { loadForeignManifests, PACKAGE_BASE } from "./kb-manifest.mjs";
40
+ import { PACKAGE_BASE } from "./content-address.mjs";
41
+ import { resolveNoteId } from "./note-ids.mjs";
42
+ import { loadForeignIndexes } from "./metadata-index.mjs";
43
+ // The record accessors only — deriving records reaches the pack router and the
44
+ // manifest emitter, which reach the compilers, which load this module. Reading
45
+ // a record needs none of that (#243).
46
+ import { authoredFrontmatter, isNoteRecord, noteFile } from "./index-records.mjs";
41
47
  import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
42
48
  // One vocabulary of link findings, and one message per class, so the three
43
49
  // resolvers cannot word the same defect differently (#184).
@@ -54,6 +60,7 @@ import { getFrontmatter } from "./frontmatter.mjs";
54
60
  export {
55
61
  getFrontmatter,
56
62
  sohlField,
63
+ folderField,
57
64
  resolveCharges,
58
65
  resolveSkillAptitudes,
59
66
  resolveRelation,
@@ -120,10 +127,74 @@ export function parseMarkdownFile(filePath) {
120
127
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
121
128
  * Defaults to the configured list.
122
129
  */
123
- export function* walkMarkdownTree(
124
- rootDir,
125
- { skipDirectories = loadPackConfig().skipDirectories } = {},
126
- ) {
130
+ /**
131
+ * Refuse a corpus read whose scope its caller did not state.
132
+ *
133
+ * The rule of #243 in one place, so every reader of the tree refuses the same
134
+ * way and says so in the same words. It is shared rather than repeated because
135
+ * the corpus is no longer read only by {@link walkMarkdownTree}: a pass that
136
+ * reads the content index instead is making the identical claim about which
137
+ * files it is looking at, and must be held to the identical requirement — a
138
+ * scope that quietly defaulted there would reintroduce exactly the second
139
+ * answer the walk's requirement removed.
140
+ *
141
+ * @param {readonly string[]|undefined} skipDirectories - The stated scope.
142
+ * @param {string} who - The reader, named in the message.
143
+ * @throws {Error} When no scope was stated.
144
+ * @returns {void}
145
+ */
146
+ export function assertStatedScope(skipDirectories, who) {
147
+ if (skipDirectories === undefined) {
148
+ throw new Error(
149
+ `${who} requires \`skipDirectories\`: the scope is the ` +
150
+ "caller's to state, so two passes cannot disagree about which " +
151
+ "files are the corpus",
152
+ );
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Refuse a corpus read whose records its caller did not supply.
158
+ *
159
+ * The sibling of {@link assertStatedScope}, and required for the same reason
160
+ * one step further on. These two readers cannot derive the corpus themselves:
161
+ * deriving it reaches the pack router and the manifest emitter, which reach the
162
+ * compilers, which load this module — so importing the index here closes a
163
+ * cycle. They take the records their caller already holds.
164
+ *
165
+ * That is not a workaround dressed up as a rule. A compile runs several passes
166
+ * over one tree, and the whole of #243 is that they must not each answer "which
167
+ * files are the corpus?" for themselves. Requiring the answer to be handed in
168
+ * makes the sharing structural rather than remembered.
169
+ *
170
+ * @param {readonly object[]|undefined} records - The supplied corpus.
171
+ * @param {string} who - The reader, named in the message.
172
+ * @throws {Error} When no corpus was supplied.
173
+ * @returns {void}
174
+ */
175
+ export function assertSuppliedCorpus(records, who) {
176
+ if (!records) {
177
+ throw new Error(
178
+ `${who} requires \`records\`: the corpus is derived once per ` +
179
+ "compile and handed to every pass, so no two passes can " +
180
+ "disagree about which files it holds",
181
+ );
182
+ }
183
+ }
184
+
185
+ export function* walkMarkdownTree(rootDir, { skipDirectories } = {}) {
186
+ // Stated by the caller, never resolved here. The default this used to carry
187
+ // — `loadPackConfig().skipDirectories` — read whichever configuration
188
+ // resolved from the working directory rather than the one the caller was
189
+ // working under. In an ordinary build those are the same object and nothing
190
+ // shows; they are not the same when a test injects a configuration, when
191
+ // `PACKAGE_BUILD_CONFIG` names one, or when the command runs from a
192
+ // worktree. Six of this function's twelve callers were on that default, so
193
+ // "which files are the corpus?" had two answers depending on who asked
194
+ // (#243) — the same defect class as `entriesForNote` reading
195
+ // `docEntryTypes` from the ambient config, fixed in #240 after a fixture
196
+ // had been passing on the leak for as long as it existed.
197
+ assertStatedScope(skipDirectories, "walkMarkdownTree");
127
198
  if (!fs.existsSync(rootDir)) return;
128
199
  const stack = [rootDir];
129
200
  while (stack.length > 0) {
@@ -152,65 +223,168 @@ export function* walkMarkdownTree(
152
223
  }
153
224
 
154
225
  /**
155
- * Resolve the required `sohl.archetype` frontmatter for an Item/Actor entry
226
+ * Every position a note may state its template priority at, for one system
227
+ * block, in the order they answer — and whichever of them the note actually
228
+ * wrote (#266).
229
+ *
230
+ * Three places, in the order the migration runs. The specification calls this
231
+ * `data.templatePriority`; `sohl-thalorna` already writes it there on 941
232
+ * notes, beside the `archetype` the build reads — so a tree that has authored
233
+ * forward is read from the key it authored, and only then does the retiring
234
+ * spelling answer.
235
+ *
236
+ * **The block is a parameter because the value is shared, not per-system.** One
237
+ * `data.templatePriority` is the note's statement that it is a template; SoHL
238
+ * records it as `system.templatePriority` and HM3 as `flags.hm3.templatePriority`.
239
+ * The legacy in-block position is therefore read from *the block being compiled*
240
+ * rather than always from `sohl:`, so an HM3-only note is not asked to author a
241
+ * SoHL block to be read.
242
+ *
243
+ * @param {object} fm Parsed frontmatter.
244
+ * @param {string} block The system block being compiled.
245
+ * @returns {{found: [object, string]|undefined, retiring: [object, string]|undefined,
246
+ * NEW: string, OLD: string}} The answering position, the retiring spelling's
247
+ * position if the note also carries it, and the two key names.
248
+ */
249
+ function findTemplatePriority(fm, block) {
250
+ const inBlock =
251
+ fm != null && typeof fm[block] === "object" && fm[block] !== null ? fm[block] : null;
252
+ const data = fm != null && typeof fm.data === "object" && fm.data !== null ? fm.data : null;
253
+
254
+ const NEW = "templatePriority";
255
+ const OLD = "archetype";
256
+ const sources = /** @type {[object|null, string][]} */ ([
257
+ [data, NEW],
258
+ [inBlock, NEW],
259
+ [fm, NEW],
260
+ [inBlock, OLD],
261
+ [fm, OLD],
262
+ ]);
263
+ const found = sources.find(([where, key]) => where != null && key in where);
264
+ // A note part-way through the rename may carry both spellings, and they may
265
+ // *disagree*: 145 of `sohl-thalorna`'s 941 dual-spelled notes say
266
+ // `templatePriority: null` where `archetype: 0` says the opposite — "not a
267
+ // template" against "a template at priority 0". Preferring the new key
268
+ // silently would flip those documents, and preferring the old would ignore
269
+ // what an author wrote most recently. Neither is a decision this function
270
+ // gets to make quietly, so a contradiction is refused and named.
271
+ const retiring = sources
272
+ .slice(sources.findIndex(([, key]) => key === OLD))
273
+ .find(([where, key]) => where != null && key in where);
274
+ return {
275
+ found: /** @type {[object, string]|undefined} */ (found),
276
+ retiring: /** @type {[object, string]|undefined} */ (retiring),
277
+ NEW,
278
+ OLD,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * The template priority a note states, for a system that treats an unstated one
284
+ * as "not a template" rather than as an authoring error (#266).
285
+ *
286
+ * Reads exactly the positions {@link resolveTemplatePriority} reads, including
287
+ * the retiring `archetype` spelling, and refuses the same contradiction — so
288
+ * the two systems cannot disagree about what a note said. It differs only in
289
+ * what silence means: SoHL requires the statement, while HM3 keeps the value in
290
+ * a flag it simply omits, so there is no tri-state for an absent value to
291
+ * corrupt and nothing to demand.
292
+ *
293
+ * @param {object} fm Parsed frontmatter.
294
+ * @param {string} label Human-readable context for error messages.
295
+ * @param {object} [options] Options.
296
+ * @param {string} [options.block="sohl"] The system block being compiled.
297
+ * @returns {number|null} The priority, or `null` when the note is not a
298
+ * template or states nothing.
299
+ * @throws {Error} When both spellings are present and disagree.
300
+ */
301
+ export function statedTemplatePriority(fm, label, { block = "sohl" } = {}) {
302
+ const { found, retiring, NEW, OLD } = findTemplatePriority(fm, block);
303
+ if (found && retiring && found[1] !== OLD && found[0][found[1]] !== retiring[0][OLD]) {
304
+ throw new Error(
305
+ `Conflicting ${NEW} for ${label}: ` +
306
+ `${NEW} is ${JSON.stringify(found[0][found[1]])} and the retiring ` +
307
+ `${OLD} is ${JSON.stringify(retiring[0][OLD])}. Both are read and ` +
308
+ `${NEW} wins, so they must agree — delete ${OLD}, or correct it`,
309
+ );
310
+ }
311
+ if (!found) return null;
312
+ const raw = found[0][found[1]];
313
+ return raw === null || raw === "" || raw === undefined ? null : raw;
314
+ }
315
+
316
+ /**
317
+ * Resolve the required `templatePriority` frontmatter for an Item/Actor entry
156
318
  * (the archetype contract, #604). The property is a nullable number that
157
319
  * authors must state explicitly:
158
- * - a number → the document is an archetype of that priority.
159
- * - `null` → the document is not an archetype.
160
- * - absent → an authoring error (throws), so "not an archetype" is never
320
+ * - a number → the document is a template of that priority.
321
+ * - `null` → the document is not a template.
322
+ * - absent → an authoring error (throws), so "not a template" is never
161
323
  * silently assumed.
162
324
  *
163
- * Reads `sohl.archetype`, falling back to a top-level `archetype` key to match
164
- * {@link sohlField}'s nested-then-top-level resolution.
325
+ * Reads the positions {@link findTemplatePriority} lists: `data.templatePriority`
326
+ * first the specified home — then the system block and the top level, and
327
+ * finally the retiring `archetype` spelling in the same two places (#266).
165
328
  *
166
329
  * @param {object} fm Parsed frontmatter.
167
330
  * @param {string} label Human-readable context for error messages.
168
- * @returns {number|undefined} The archetype priority, or `undefined` when null.
169
- * @throws {Error} When `sohl.archetype` is absent or is not a number/null.
331
+ * @param {object} [options] Options.
332
+ * @param {string} [options.block="sohl"] The system block being compiled.
333
+ * @returns {number|undefined} The template priority, or `undefined` when null.
334
+ * @throws {Error} When the property is absent, is not a number/null, or both
335
+ * spellings are present and disagree.
170
336
  */
171
- export function resolveArchetype(fm, label) {
172
- const sohl = fm != null && typeof fm.sohl === "object" ? fm.sohl : null;
173
- const inSohl = sohl != null && "archetype" in sohl;
174
- const inTop = fm != null && typeof fm === "object" && "archetype" in fm;
175
- if (!inSohl && !inTop) {
337
+ export function resolveTemplatePriority(fm, label, { block = "sohl" } = {}) {
338
+ const { found, retiring, NEW, OLD } = findTemplatePriority(fm, block);
339
+
340
+ if (found && retiring && found[1] !== OLD && found[0][found[1]] !== retiring[0][OLD]) {
341
+ throw new Error(
342
+ `Conflicting ${NEW} for ${label}: ` +
343
+ `${NEW} is ${JSON.stringify(found[0][found[1]])} and the retiring ` +
344
+ `${OLD} is ${JSON.stringify(retiring[0][OLD])}. Both are read and ` +
345
+ `${NEW} wins, so they must agree — delete ${OLD}, or correct it`,
346
+ );
347
+ }
348
+ if (!found) {
176
349
  throw new Error(
177
- `Missing required sohl.archetype for ${label} — set a number (this is an archetype) or null (it is not)`,
350
+ `Missing required ${NEW} for ${label} — set a number (this is a template, at that priority) or null (it is not)`,
178
351
  );
179
352
  }
180
- const raw = inSohl ? sohl.archetype : fm.archetype;
353
+ const raw = found[0][found[1]];
181
354
  if (raw === null) return undefined;
182
355
  if (typeof raw !== "number" || !Number.isFinite(raw)) {
183
356
  throw new Error(
184
- `Invalid sohl.archetype for ${label}: expected a number or null, got ${JSON.stringify(raw)}`,
357
+ `Invalid ${found[1]} for ${label}: expected a number or null, got ${JSON.stringify(raw)}`,
185
358
  );
186
359
  }
187
360
  return raw;
188
361
  }
189
362
 
190
363
  /**
191
- * The value a document's `system.archetype` carries, from the required
192
- * `sohl.archetype` frontmatter (#126, sohl#1780).
364
+ * The value a document's `system.templatePriority` carries, from the required
365
+ * `templatePriority` frontmatter (#126, sohl#1780, renamed off `archetype` by
366
+ * #266 / sohl#1836).
193
367
  *
194
368
  * A **schema field**, so the tri-state is written out in full rather than
195
- * expressed by a key's presence: a number is an archetype at that priority,
196
- * and `null` is not an archetype. This is where {@link resolveArchetype}'s
369
+ * expressed by a key's presence: a number is a template at that priority, and
370
+ * `null` is not a template. This is where {@link resolveTemplatePriority}'s
197
371
  * `undefined` becomes the field's `null` — an emitted `undefined` would be
198
372
  * dropped by `JSON.stringify`, leaving the compiled document with no
199
- * `archetype` at all and the tri-state readable as two.
373
+ * `templatePriority` at all and the tri-state readable as two.
200
374
  *
201
- * **`0` is an archetype.** It is the priority SoHL's own archetypes ship at,
202
- * and it is falsy, so this returns it unchanged and every caller must ask
375
+ * **`0` is a template.** It is the priority SoHL's own templates ship at, and
376
+ * it is falsy, so this returns it unchanged and every caller must ask
203
377
  * `typeof v === "number"` rather than testing truthiness.
204
378
  *
205
379
  * @param {object} fm Parsed frontmatter.
206
380
  * @param {string} label Human-readable context for error messages.
207
- * @returns {number|null} The archetype priority, or `null` for a document
208
- * that is not an archetype.
209
- * @throws {Error} When `sohl.archetype` is absent or invalid.
381
+ * @returns {number|null} The template priority, or `null` for a document that
382
+ * is not a template.
383
+ * @throws {Error} When the property is absent or invalid.
210
384
  */
211
- export function systemArchetype(fm, label) {
212
- const archetype = resolveArchetype(fm, label);
213
- return archetype === undefined ? null : archetype;
385
+ export function systemTemplatePriority(fm, label) {
386
+ const priority = resolveTemplatePriority(fm, label);
387
+ return priority === undefined ? null : priority;
214
388
  }
215
389
 
216
390
  /**
@@ -434,6 +608,7 @@ export { makeId } from "./ids.mjs";
434
608
  // The content-type → document-type map, which decides *which* pack list a
435
609
  // note's own document is routed against.
436
610
  import { assertTypeNotRetired, packForType } from "./ids.mjs";
611
+ import { collectAnchors } from "./anchors.mjs";
437
612
 
438
613
  /* ------------------------------------------------------------------------ */
439
614
  /* Wikilink resolution: the content-wide link index */
@@ -461,16 +636,39 @@ import { assertTypeNotRetired, packForType } from "./ids.mjs";
461
636
  * this repository's own.
462
637
  * @returns {{byShortcode: Map, types: Set}} From `buildWikilinkIndex`.
463
638
  */
464
- export function buildContentLinkIndex(contentBase, router = packRouter()) {
639
+ export function buildContentLinkIndex(
640
+ contentBase,
641
+ router = packRouter(),
642
+ { skipDirectories, config, records, problems } = {},
643
+ ) {
465
644
  const docs = [];
466
- for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase)) {
645
+ const resolved = config ?? loadPackConfig();
646
+ assertSuppliedCorpus(records, "buildContentLinkIndex");
647
+ for (const record of records) {
648
+ // A documentation journal is a document this tree emits, not a note in
649
+ // it; the note it documents is indexed here and carries its address.
650
+ if (!isNoteRecord(record)) continue;
651
+ // The note as its author wrote it — the router, the draft tag and the
652
+ // retired-type check all read authored fields, and none of them may be
653
+ // handed the keys the index derived.
654
+ const fm = authoredFrontmatter(record);
655
+ const absPath = noteFile(contentBase, record);
656
+ // The id a note's document is filed under: its authored pin, or the
657
+ // one derived from its canonical address (#270). Derived by the index
658
+ // against the configuration this build resolved — it used to be
659
+ // derived here through `resolveNoteId(fm)` with no package, which falls
660
+ // back to the ambient `contentPackage()` and so to whichever
661
+ // configuration the working directory answers with (#243).
662
+ // What is left after that is a file with **no address** — no type, or
663
+ // no shortcode — which is not an addressable note and has no document
664
+ // to link to.
467
665
  if (!fm?.id) continue;
468
666
  // The first walk of every note in the tree, and the only one holding
469
667
  // both the declared type and the file that declares it — so a note
470
668
  // left on a retired type is reported here, by name, rather than
471
669
  // several frames deeper with nothing to go on (SoHL#1580).
472
670
  assertTypeNotRetired(fm.type, absPath);
473
- const base = path.basename(absPath, ".md").replace(/_/g, " ");
671
+ const base = String(record.file.name).replace(/_/g, " ");
474
672
  docs.push({
475
673
  type: fm.type,
476
674
  id: fm.id,
@@ -485,32 +683,40 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
485
683
  // *into* this note renders marked. It takes no part in resolution,
486
684
  // so the note is indexed, compiled and published as any other.
487
685
  draft: isDraftNote(fm),
686
+ // The anchors this note declares, carried so the *builds* can check
687
+ // a `#section` link and not only the checker (#193). A foreign
688
+ // anchor has always been checked, because a fetched index
689
+ // publishes the map; a local one was not, because the set was
690
+ // discarded here — the walk yields the body and nothing read it.
691
+ // Read from the record rather than from a second reading of the
692
+ // note's headings — the one-anchor-reader rule (#243).
693
+ anchors: new Set((record.anchors ?? []).map((anchor) => anchor.slug)),
488
694
  });
489
695
  }
490
- // Packages this build links *into* but does not publish. Their manifests
491
- // are vendored and committed, so a contributor without every repository
492
- // checked out resolves the same links CI does (#1446, #1499).
493
- // Packages this repository links into but does not publish; their vendored
494
- // manifests live at the configured location (#1446, #1499).
495
- const { index: foreign, stale } = loadForeignManifests(
496
- loadPackConfig().paths.manifests,
497
- [contentPackage()],
696
+ // Packages this build links *into* but does not publish. Each publishes
697
+ // its own content index and this build fetched the ones it depends on, so
698
+ // a contributor without every repository checked out resolves the same
699
+ // links CI does from an artifact the producer shipped rather than a copy
700
+ // this repository committed (#239).
701
+ const { index: foreign, stale } = loadForeignIndexes(
702
+ resolved,
703
+ [resolved.contentPackage],
498
704
  PACKAGE_BASE,
499
705
  );
500
706
  if (stale.length) {
501
707
  for (const st of stale) {
502
- log.error(`Unusable link manifest for "${st.package}": ${st.reason}`);
708
+ log.error(`Unusable content index for "${st.package}": ${st.reason}`);
503
709
  }
504
710
  throw new Error(
505
- "Cross-package links cannot be resolved from a stale manifest; " +
506
- "re-vendor it from that package's build.",
711
+ "Cross-package links cannot be resolved from an unusable index; " +
712
+ "re-run `content-build deps fetch`.",
507
713
  );
508
714
  }
509
715
  log.debug(
510
716
  `Wikilink index: ${docs.length} local document(s), ` +
511
717
  `${foreign.size} foreign address(es)`,
512
718
  );
513
- return buildWikilinkIndex(docs, foundryPackageId(), foreign, contentPackage());
719
+ return buildWikilinkIndex(docs, resolved.foundryPackage, foreign, resolved.contentPackage);
514
720
  }
515
721
 
516
722
  /**
@@ -609,16 +815,25 @@ export function convertNoteWikilinks(
609
815
  * @returns {Array<{fm: object, path: string, tld: string, folder: string,
610
816
  * absPath: string}>}
611
817
  */
612
- export function collectContentDocs(contentBase) {
818
+ export function collectContentDocs(
819
+ contentBase,
820
+ { skipDirectories, config, records, problems } = {},
821
+ ) {
613
822
  const docs = [];
614
- for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase)) {
615
- if (!fm) continue;
616
- const segments = path.relative(contentBase, absPath).split(path.sep);
823
+ const resolved = config ?? loadPackConfig();
824
+ assertSuppliedCorpus(records, "collectContentDocs");
825
+ for (const record of records) {
826
+ if (!isNoteRecord(record)) continue;
827
+ const fm = authoredFrontmatter(record);
828
+ const absPath = noteFile(contentBase, record);
829
+ const segments = String(record.file.path).split("/");
617
830
  docs.push({
618
831
  // With its package supplied for a `WHERE … package = "…"` query —
619
- // synthesised from the configuration, since no note declares it
620
- // (#56).
621
- fm: searchableFrontmatter(fm),
832
+ // synthesised from the configuration this build resolved, since no
833
+ // note declares it (#56) and the ambient one is a different
834
+ // configuration in a worktree or under `PACKAGE_BUILD_CONFIG`
835
+ // (#243).
836
+ fm: searchableFrontmatter(fm, resolved.contentPackage),
622
837
  // POSIX-separated and relative to the content root — what a
623
838
  // `path:` search term globs, on every platform.
624
839
  path: segments.join("/"),
@@ -670,7 +885,7 @@ const packLinkable = (doc) => Boolean(doc.fm?.shortcode) && Boolean(doc.fm?.type
670
885
  * compile rather than shipping a table-shaped hole. The error carries
671
886
  * `position`, the directive's own line.
672
887
  */
673
- export function expandNoteTables(body, { docs, name, fm, bodyLine }) {
888
+ export function expandNoteTables(body, { docs, name, fm, bodyLine, sqlTables }) {
674
889
  const self =
675
890
  fm ?
676
891
  (docs.find((d) => d.fm?.id && d.fm.id === fm.id) ?? {
@@ -682,6 +897,7 @@ export function expandNoteTables(body, { docs, name, fm, bodyLine }) {
682
897
  linkable: packLinkable,
683
898
  source: name,
684
899
  self,
900
+ sqlTables,
685
901
  });
686
902
  if (errors.length) {
687
903
  const err = new Error(errors.map((e) => `content table — ${e.reason}`).join("; "));
@@ -767,14 +983,27 @@ export function buildFolderResolver(folders) {
767
983
  siblings.add(f.name);
768
984
  }
769
985
 
770
- function resolver(folderId) {
771
- if (folderId == null || folderId === "") return null;
772
- const id = String(folderId).trim();
773
- if (!id) return null;
774
- if (!byId.has(id)) {
775
- throw new Error(`Unknown folder id "${id}"`);
986
+ /**
987
+ * The folder id a note names, by id.
988
+ *
989
+ * **Only by id.** This resolver answers for `folder:` alone; `packFolder:`
990
+ * names a folder *note* and is resolved through the address index instead
991
+ * (#255). The path lookup that briefly lived here is gone with the path
992
+ * spelling it served — it was never released, so there is nothing to
993
+ * deprecate.
994
+ *
995
+ * @param {string|null|undefined} value - As authored.
996
+ * @returns {string|null} The id, or `null` for an absent value.
997
+ * @throws {Error} When the id is not one this pack declares.
998
+ */
999
+ function resolver(value) {
1000
+ if (value == null || value === "") return null;
1001
+ const authored = String(value).trim();
1002
+ if (!authored) return null;
1003
+ if (!byId.has(authored)) {
1004
+ throw new Error(`Unknown folder id "${authored}"`);
776
1005
  }
777
- return id;
1006
+ return authored;
778
1007
  }
779
1008
 
780
1009
  return { resolver, folders };
package/engine/ids.mjs CHANGED
@@ -64,6 +64,28 @@ export const MAP_TYPES = Object.freeze(new Set(["map"]));
64
64
  */
65
65
  export const MAP_SUBTYPES = Object.freeze(["battlemap", "localmap", "regionalmap"]);
66
66
 
67
+ /**
68
+ * Content types whose whole document **is** a JournalEntry.
69
+ *
70
+ * Prose, and nothing else: each compiles into one journal entry of its own,
71
+ * with no second document to point at. That is what separates them from the
72
+ * doc-carrying types in `item-docs.mjs`, whose prose becomes a journal *beside*
73
+ * an item, a macro or a scene — those are two documents, and the pair is
74
+ * addressed as `<type>` and `doc<type>`. These are one, so there is no
75
+ * `docplace` and nothing synthesizes one.
76
+ *
77
+ * `doc` was the only member until #241. `place`, `lore` and `scenario` are in
78
+ * the published content format and were declared for validation in #233, but
79
+ * nothing routed them: a note of one lint-ed clean and then compiled into
80
+ * nothing, because {@link PACK_BY_TYPE} did not name it and the open-set
81
+ * default sent it to the items pack. `sohl-thalorna` could not compile a single
82
+ * pack for exactly this reason — 450 notes, and the same 450 the linter had
83
+ * reported before it learned the types.
84
+ *
85
+ * @type {ReadonlySet<string>}
86
+ */
87
+ export const JOURNAL_TYPES = Object.freeze(new Set(["doc", "place", "lore", "scenario"]));
88
+
67
89
  /**
68
90
  * Content type → the pack its documents compile into, and the document type
69
91
  * that pack holds.
@@ -78,9 +100,17 @@ export const MAP_SUBTYPES = Object.freeze(["battlemap", "localmap", "regionalmap
78
100
  * @type {Readonly<Record<string, {pack: string, docType: string}>>}
79
101
  */
80
102
  export const PACK_BY_TYPE = Object.freeze({
81
- doc: { pack: "journals", docType: "JournalEntry" },
103
+ ...Object.fromEntries(
104
+ [...JOURNAL_TYPES].map((type) => [type, { pack: "journals", docType: "JournalEntry" }]),
105
+ ),
82
106
  macro: { pack: "macros", docType: "Macro" },
83
107
  being: { pack: "actors", docType: "Actor" },
108
+ // A bundle is Foundry's `Adventure` — a set of document *copies* packaged
109
+ // for one-shot import (#259). The conventional name is `adventures`, which
110
+ // is also what the scenes pass calls its **companion**; the two are
111
+ // different packs and a repository shipping both names them apart, since
112
+ // the router refuses a note that addresses a companion.
113
+ bundle: { pack: "adventures", docType: "Adventure" },
84
114
  ...Object.fromEntries(
85
115
  [...MAP_TYPES].map((type) => [type, { pack: "scenes", docType: "Scene" }]),
86
116
  ),
@@ -134,6 +164,106 @@ export function assertTypeNotRetired(type, where) {
134
164
  );
135
165
  }
136
166
 
167
+ /* -------------------------------------------------------------------- */
168
+ /* Renamed in favour of another type — the retirement window */
169
+ /* -------------------------------------------------------------------- */
170
+
171
+ /**
172
+ * Content types that were **renamed**, and what each is called now.
173
+ *
174
+ * A retired type and a renamed one are different cases, and only the first can
175
+ * be refused. `character` above was retired *outright* — nothing a note wrote
176
+ * made it right, so {@link assertTypeNotRetired} throws. A renamed type has a
177
+ * replacement, the two spellings mean the same thing, and a note carrying the
178
+ * old one compiles into exactly the document it always did: refusing it would
179
+ * fail a build over a note that is not wrong.
180
+ *
181
+ * So these retire in the three steps `package:` took (#56), and this table is
182
+ * the **first**: both spellings resolve, the current one is canonical, and the
183
+ * retired one is *reported* — never refused. The sweep of the content trees and
184
+ * the refusal come after, once no tree writes the old name. That is the same
185
+ * rule `RETIRED_FIELD_ALIASES` states for a renamed *field*, and for the same
186
+ * reason: a consumer must be able to adopt the new toolchain before its content
187
+ * moves, and there are some 31,000 references to move.
188
+ *
189
+ * **Why these three (#78).** `armorgear`, `concoctiongear` and `projectilegear`
190
+ * named the *SoHL document subtype* a note happened to compile into rather than
191
+ * the thing the note is about. A note's `type` sits outside the `sohl:` and
192
+ * `hm3:` blocks precisely because it belongs to no system, and HM3 already
193
+ * compiles a `projectile` into a `missilegear` — so the suffix was never a fact
194
+ * about the note. `weapongear` keeps its name: SoHL and HM3 both call the
195
+ * document that, and #78's table has no row for it.
196
+ *
197
+ * **Keyed by the retired spelling**, which is the opposite of
198
+ * `RETIRED_FIELD_ALIASES`. The direction follows the operation: a field alias
199
+ * is scoped by the schema that declares the *current* name, so that is what a
200
+ * reader asks with; a type alias is applied to a value read off a note, so what
201
+ * a reader has in hand is the retired spelling and what it needs is the
202
+ * replacement.
203
+ *
204
+ * **The address is not normalised.** A note's canonical address — and therefore
205
+ * its document id — carries its `type` as authored, so both spellings keep the
206
+ * addresses they already publish and a tree that has not swept compiles
207
+ * byte-identically. Renaming the type in a note *is* an address move, which is
208
+ * the sweep's business and not this table's.
209
+ *
210
+ * @type {Readonly<Record<string, string>>}
211
+ */
212
+ export const RENAMED_TYPES = Object.freeze({
213
+ armorgear: "armor",
214
+ concoctiongear: "concoction",
215
+ projectilegear: "projectile",
216
+ });
217
+
218
+ /**
219
+ * The current spelling of a content type: itself, or what it was renamed to.
220
+ *
221
+ * Every table keyed by note type is keyed by the **current** name, and every
222
+ * lookup goes through this — which is the whole of the window's behaviour, in
223
+ * one function, so no two readers can disagree about which vocabulary a note
224
+ * is held to.
225
+ *
226
+ * A non-string passes through untouched: callers hand this whatever the
227
+ * frontmatter carried, and inventing a type for a number would hide the
228
+ * missing-`type:` finding that belongs to the linter.
229
+ *
230
+ * @param {any} type - The type as authored, or as a link spells it.
231
+ * @returns {any} The current spelling.
232
+ */
233
+ export function currentType(type) {
234
+ if (typeof type !== "string") return type;
235
+ return RENAMED_TYPES[type] ?? type;
236
+ }
237
+
238
+ /**
239
+ * What a note declaring a renamed type is told, in one place.
240
+ *
241
+ * Shared by every reporter, because an author meets whichever runs first and
242
+ * they should read the same. It names the type to write rather than a value to
243
+ * correct — no value makes the retired spelling right — and it says the note
244
+ * compiles either way, so a reader knows this is a rename to schedule rather
245
+ * than a build to unbreak.
246
+ *
247
+ * @param {string} retired - The spelling the note used.
248
+ * @param {string} current - What to write instead.
249
+ * @param {string} [where] - What carries it — a file path, a link target —
250
+ * appended to the message. Omit it where the caller emits through a
251
+ * diagnostic, whose locator already starts the line.
252
+ * @returns {string} The message, unpunctuated at the end as a finding is.
253
+ */
254
+ export function renamedTypeMessage(retired, current, where) {
255
+ return (
256
+ `content type "${retired}" was renamed to "${current}" — the suffix ` +
257
+ `named a SoHL document subtype rather than the thing the note is ` +
258
+ `about` +
259
+ (where ? ` — ${where}` : "") +
260
+ `. Both spellings compile to the same document, so the note is not ` +
261
+ `wrong; write "${current}", and move every \`(type, shortcode)\` ` +
262
+ `reference and wikilink to it in the same change. "${retired}" is ` +
263
+ `refused in a later release`
264
+ );
265
+ }
266
+
137
267
  /** Where every other content type compiles: the items pack. */
138
268
  export const ITEM_PACK = Object.freeze({ pack: "items", docType: "Item" });
139
269
 
@@ -162,7 +292,12 @@ export const ITEM_PACK = Object.freeze({ pack: "items", docType: "Item" });
162
292
  */
163
293
  export function packForType(type) {
164
294
  assertTypeNotRetired(type);
165
- return PACK_BY_TYPE[type] ?? ITEM_PACK;
295
+ // Through {@link currentType} like every other type-keyed lookup, so the
296
+ // rule holds without exception. None of today's renamed types is named in
297
+ // `PACK_BY_TYPE` — all three are items and take the default — but a router
298
+ // that read the authored spelling would be the one table left to remember
299
+ // when the next rename lands.
300
+ return PACK_BY_TYPE[currentType(type)] ?? ITEM_PACK;
166
301
  }
167
302
 
168
303
  /**