@heroiclands/package-build 19.0.0 → 20.2.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 (85) hide show
  1. package/CHANGELOG.md +1100 -0
  2. package/CONTENT.md +264 -33
  3. package/README.md +43 -4
  4. package/bin/content-build.mjs +94 -3
  5. package/config.mjs +9 -1
  6. package/content-config.mjs +99 -19
  7. package/docs/content-format.md +394 -72
  8. package/e2e.mjs +297 -3
  9. package/engine/actor-compiler.mjs +197 -7
  10. package/engine/address-charset.mjs +23 -5
  11. package/engine/base-compiler.mjs +63 -2
  12. package/engine/bundles.mjs +9 -0
  13. package/engine/content-address.mjs +92 -1
  14. package/engine/content-charset.mjs +434 -0
  15. package/engine/content-format.mjs +102 -0
  16. package/engine/content-icons.mjs +388 -0
  17. package/engine/content-index.mjs +11 -8
  18. package/engine/content-links.mjs +37 -21
  19. package/engine/field-reference.mjs +57 -5
  20. package/engine/field-spec.mjs +214 -7
  21. package/engine/folder-notes.mjs +24 -1
  22. package/engine/foreign-catalog.mjs +112 -7
  23. package/engine/foundry-entries.mjs +14 -0
  24. package/engine/frontmatter-lint.mjs +377 -56
  25. package/engine/frontmatter.mjs +11 -11
  26. package/engine/generate.mjs +72 -12
  27. package/engine/helpers.mjs +96 -10
  28. package/engine/index.mjs +9 -0
  29. package/engine/item-compiler.mjs +37 -0
  30. package/engine/journals.mjs +21 -4
  31. package/engine/macros.mjs +8 -0
  32. package/engine/map-notes.mjs +7 -7
  33. package/engine/note-claims.mjs +208 -5
  34. package/engine/note-ids.mjs +25 -1
  35. package/engine/note-vocabulary.mjs +76 -9
  36. package/engine/pack-config.mjs +102 -12
  37. package/engine/pack-router.mjs +0 -0
  38. package/engine/prose-config.mjs +42 -0
  39. package/engine/prose-lint.mjs +126 -0
  40. package/engine/retired-fields.mjs +57 -16
  41. package/engine/runtime-only-fields.mjs +204 -0
  42. package/engine/scenes.mjs +12 -19
  43. package/engine/schema-check.mjs +23 -1
  44. package/engine/schema-extract.mjs +13 -0
  45. package/engine/site-index.mjs +17 -0
  46. package/engine/subtype-registry.mjs +30 -0
  47. package/engine/system-block.mjs +81 -3
  48. package/engine/web-wikilinks.mjs +33 -27
  49. package/engine/wikilink-syntax.mjs +7 -0
  50. package/engine/wikilinks.mjs +74 -16
  51. package/hm3/actors.mjs +63 -13
  52. package/package.json +2 -2
  53. package/sohl/actors.mjs +106 -7
  54. package/sohl/item-fields.mjs +203 -0
  55. package/sohl/note-schemas.mjs +6 -3
  56. package/types/config.d.mts +7 -0
  57. package/types/e2e.d.mts +130 -3
  58. package/types/engine/actor-compiler.d.mts +83 -3
  59. package/types/engine/address-charset.d.mts +22 -4
  60. package/types/engine/base-compiler.d.mts +54 -3
  61. package/types/engine/content-address.d.mts +64 -0
  62. package/types/engine/content-charset.d.mts +127 -0
  63. package/types/engine/content-format.d.mts +9 -0
  64. package/types/engine/content-icons.d.mts +151 -0
  65. package/types/engine/field-spec.d.mts +271 -3
  66. package/types/engine/folder-notes.d.mts +20 -0
  67. package/types/engine/foreign-catalog.d.mts +38 -2
  68. package/types/engine/foundry-entries.d.mts +6 -0
  69. package/types/engine/frontmatter-lint.d.mts +164 -30
  70. package/types/engine/frontmatter.d.mts +11 -11
  71. package/types/engine/generate.d.mts +27 -0
  72. package/types/engine/helpers.d.mts +45 -9
  73. package/types/engine/index.d.mts +3 -0
  74. package/types/engine/map-notes.d.mts +2 -2
  75. package/types/engine/note-claims.d.mts +67 -0
  76. package/types/engine/note-ids.d.mts +14 -0
  77. package/types/engine/pack-config.d.mts +35 -0
  78. package/types/engine/prose-config.d.mts +41 -0
  79. package/types/engine/prose-lint.d.mts +36 -0
  80. package/types/engine/retired-fields.d.mts +29 -13
  81. package/types/engine/runtime-only-fields.d.mts +102 -0
  82. package/types/engine/schema-check.d.mts +10 -1
  83. package/types/engine/subtype-registry.d.mts +21 -0
  84. package/types/engine/system-block.d.mts +28 -2
  85. package/types/sohl/actors.d.mts +3 -3
@@ -34,7 +34,7 @@
34
34
  * @module
35
35
  */
36
36
 
37
- import { authoredFields } from "./field-spec.mjs";
37
+ import { authoredFields, runtimeOnlyFields } from "./field-spec.mjs";
38
38
  import { loadPackConfig } from "./pack-config.mjs";
39
39
 
40
40
  /**
@@ -104,7 +104,15 @@ function fieldTable(fields) {
104
104
  `\`${field.name}\``,
105
105
  cell(field.shape ?? "as authored"),
106
106
  field.required ? "**yes**" : "no",
107
- field.required ? "—" : renderDefault(field.default),
107
+ // Three different answers, and the third is not a value (#329). A
108
+ // required field has no default because omitting it fails the
109
+ // build; an `omitWhenAbsent` field has none because omitting it
110
+ // omits the *key*, leaving the data model to answer. Rendering
111
+ // both as the em dash a missing default already prints would put
112
+ // two opposite behaviours in one cell.
113
+ field.required ? "—"
114
+ : field.omitWhenAbsent ? "_omitted_"
115
+ : renderDefault(field.default),
108
116
  cell(field.describe ?? ""),
109
117
  ]),
110
118
  ];
@@ -139,6 +147,40 @@ function sharedExemptions(fields) {
139
147
  ]);
140
148
  }
141
149
 
150
+ /**
151
+ * The fields of one type that a note may **never** write (#330).
152
+ *
153
+ * A type's table lists what an author writes, and says nothing about the rest
154
+ * of its schema — which is right for a constant or a derived value, since
155
+ * nothing happens if a note writes the path anyway. A runtime-only field is
156
+ * different in the way that matters to a reader: it is a real part of the
157
+ * document, it is spelled beside fields they *do* write — `onsetDate` sits next
158
+ * to `onsetDurationFormula` — and authoring it fails the build. Leaving it
159
+ * unmentioned means an author meets the rule as an error rather than as
160
+ * documentation.
161
+ *
162
+ * Rendered from each field's own reason, for the same purpose
163
+ * {@link sharedExemptions} renders `topLevelMeans` for: the declaration already
164
+ * states it, and a page restating it in other words is a second copy to keep
165
+ * true.
166
+ *
167
+ * @param {readonly object[]} fields - The type's declaration.
168
+ * @returns {string[]} Markdown lines, empty when the type declares none.
169
+ */
170
+ function runtimeState(fields) {
171
+ const runtime = runtimeOnlyFields(/** @type {never} */ (fields));
172
+ if (!runtime.length) return [];
173
+ return [
174
+ "**Never authored.** These fields are part of the document and are " +
175
+ "written during play, so a note that declares one fails the build. " +
176
+ "Left out of the compiled document entirely, they carry the data " +
177
+ "model's own initial value until play writes them.",
178
+ "",
179
+ ...runtime.map((field) => `- \`${field.to}\` — ${cell(field.runtimeOnly)}`),
180
+ "",
181
+ ];
182
+ }
183
+
142
184
  /**
143
185
  * A minimal note for one type: the frontmatter envelope every note carries,
144
186
  * plus exactly the `sohl:` fields the type requires.
@@ -168,7 +210,13 @@ function workedExample(type, fields) {
168
210
  // No `package:`. A note's package is the repository's configured
169
211
  // `contentPackage`, and declaring the field is a build error (#56) —
170
212
  // this example is the smallest note that compiles.
171
- "id: <16-character id>",
213
+ //
214
+ // No `id:` either, for the same reason it is not shown optional-first:
215
+ // a note's document `_id` derives from its canonical address (#270,
216
+ // #277), and the authored field is the escape hatch for keeping a
217
+ // document's identity across a shortcode rename, not part of the
218
+ // envelope. This block is the one an author copies as a template, so
219
+ // showing the field taught every note in the tree to write it (#314).
172
220
  "sohl:",
173
221
  " templatePriority: null",
174
222
  ];
@@ -221,8 +269,11 @@ export function renderItemFieldReference({
221
269
  `repository compiles declare their frontmatter below. Every field ` +
222
270
  `is written under a note's \`sohl:\` block; a dotted name such as ` +
223
271
  `\`impact.die\` is a nested key. A field a note does not carry ` +
224
- `takes the default shown, and a **required** field has none — ` +
225
- `omitting it fails the build rather than guessing.`,
272
+ `takes the default shown; a **required** field has none — omitting ` +
273
+ `it fails the build rather than guessing — and one shown as ` +
274
+ `_omitted_ has none either, because leaving it out leaves the key ` +
275
+ `out of the compiled document, so the data model's own initial ` +
276
+ `value stands.`,
226
277
  "",
227
278
  );
228
279
 
@@ -233,6 +284,7 @@ export function renderItemFieldReference({
233
284
  ...fieldTable(declared[type]),
234
285
  "",
235
286
  ...sharedExemptions(declared[type]),
287
+ ...runtimeState(declared[type]),
236
288
  ...workedExample(type, declared[type]),
237
289
  "",
238
290
  );
@@ -43,9 +43,9 @@
43
43
  * @module
44
44
  */
45
45
 
46
- import { legacyKeyOf, resolveFieldValue, setPath } from "./system-block.mjs";
46
+ import { legacyKeyOf, resolveFieldValue, retiredTopLevelKey, setPath } from "./system-block.mjs";
47
47
 
48
- export { legacyKeyOf, setPath };
48
+ export { legacyKeyOf, retiredTopLevelKey, setPath };
49
49
 
50
50
  /**
51
51
  * @typedef {object} FieldSpec
@@ -109,6 +109,13 @@ export { legacyKeyOf, setPath };
109
109
  * describe the *document* rather than the note: `<system>.system.<to>` and
110
110
  * the legacy in-block `<system>.<name>`. Absent means the ordinary case —
111
111
  * the top level is read, as the third step.
112
+ *
113
+ * **It is read from the other side too**, because the statement is symmetric:
114
+ * if the two positions hold unrelated quantities then the *in-block* position
115
+ * is not the note-level field either, so a check about the note-level field
116
+ * reads past it. `engine/frontmatter-lint.mjs` resolves that through
117
+ * `collidingBlockKeys`. Read for the emitted field alone until #312, which is
118
+ * how an affiliation's office style came to answer for its page heading.
112
119
  * @property {string} [shape] - Human-readable shape, for documentation. Comes
113
120
  * paired with `read` from one of the coercion constants below.
114
121
  * @property {(raw: any, ctx: {fm: object, field: FieldSpec}) => any} [read] -
@@ -138,6 +145,78 @@ export { legacyKeyOf, setPath };
138
145
  * note, so it declares none.
139
146
  * @property {any|((fm: object) => any)} [value] - For a field with no `name`:
140
147
  * the constant, or a function deriving it from the frontmatter.
148
+ * @property {boolean} [omitWhenAbsent] - **The key is left out entirely when
149
+ * the note does not carry the field** (#329), rather than written from a
150
+ * declared default.
151
+ *
152
+ * Every other field answers absence with a value: an unauthored `weight` is
153
+ * `0`, an unauthored `seat` is `null`. That is right wherever the type has an
154
+ * opinion about the empty case. It is wrong wherever the **DataModel** is the
155
+ * one holding the answer — an affliction's `onsetDurationFormula` has no
156
+ * compile-time value, and writing `null` over it does not merely fail to
157
+ * help: it makes "the author said none" and "the author said nothing"
158
+ * indistinguishable to every reader downstream, and it overwrites an
159
+ * `initial` the system chose on purpose.
160
+ *
161
+ * It is the other conditional row of the same table
162
+ * {@link FieldSpec.runtimeOnly} completes, and the two differ only in what
163
+ * they do about an *authored* value:
164
+ *
165
+ * | declaration | authored | absent |
166
+ * | --- | --- | --- |
167
+ * | ordinary | emitted | default written |
168
+ * | `omitWhenAbsent` | emitted | key omitted |
169
+ * | `runtimeOnly` | refused | key omitted |
170
+ *
171
+ * **A field declaring it must declare no `default`**, and the two are
172
+ * contradictory rather than merely redundant — a default is a value for the
173
+ * absent case, which is the case this says has none. Nor may it be combined
174
+ * with `required` (which fails the build on absence, so nothing is ever
175
+ * omitted) or with `runtimeOnly` (which is never emitted at all). The shipped
176
+ * declarations are checked for all three in `tests/item-fields.test.ts`.
177
+ *
178
+ * Unlike `runtimeOnly` this is a flag rather than a reason, because there is
179
+ * only ever one reason and no message prints it: the DataModel's `initial`
180
+ * stands. What an author needs to know is *that* the field has no default,
181
+ * which the generated reference states in the field's own row.
182
+ * @property {string} [runtimeOnly] - **What the field holds once play has
183
+ * started** — declared on a field the *document* writes for itself, which no
184
+ * note may author (#330).
185
+ *
186
+ * A schema declares plenty of fields a compiled document has no business
187
+ * carrying: an affliction's `onsetDate` is the world time its onset fired
188
+ * at, crystallized when the phase runs. World time does not exist while
189
+ * content is compiled, so there is no authoring-time value — and `0` is
190
+ * itself a valid world time, which is why such a field is nullable rather
191
+ * than sentinelled and why a default cannot stand in for one.
192
+ *
193
+ * Nothing used to stop a note writing one. The three checks that might have
194
+ * each declined for its own correct reason —
195
+ * {@link module:engine/system-block.unknownBlockKeys} reads the block's top
196
+ * level and never descends into `system:`;
197
+ * {@link module:engine/system-block.mergeSystemData} passes through every
198
+ * authored path no declared field claims; and the schema check's fatal
199
+ * direction is *undeclared*, which a field the schema really does declare
200
+ * satisfies. What was missing was a rule saying "declared by the system,
201
+ * but never authorable", and this is it.
202
+ *
203
+ * Declaring it does two things, which are the two directions of one fact:
204
+ *
205
+ * | declaration | authored | absent |
206
+ * | --- | --- | --- |
207
+ * | ordinary | emitted | default written |
208
+ * | runtime-only | **refused** | key omitted |
209
+ *
210
+ * The refusal is {@link module:engine/runtime-only-fields.assertNoRuntimeOnlyFields}'s;
211
+ * the omission is {@link buildFromFields}'s. A runtime-only entry declares a
212
+ * `to` and **no `name`**, so it stays out of {@link authoredFields} and every
213
+ * author-facing surface built on it, while still giving `mergeSystemData` a
214
+ * claimed path and the refusal something to name.
215
+ *
216
+ * **The value is the reason**, as {@link FieldSpec.topLevelMeans}'s is: a
217
+ * boolean would record the decision and lose the case for it, and the reason
218
+ * is what the refusal's message and the generated reference both print. It
219
+ * completes the sentence "it holds …".
141
220
  * @property {string} describe - One line, for the author-facing reference.
142
221
  */
143
222
 
@@ -228,6 +307,29 @@ export function readsLegacyKey(field, from) {
228
307
  return field?.legacyKey !== undefined && from === "block";
229
308
  }
230
309
 
310
+ /**
311
+ * Whether a resolution read a field from the top-level key `data:` gathered it
312
+ * off — the shared level's retiring position (#332).
313
+ *
314
+ * {@link readsLegacyKey}'s sibling, and the same signal: a finding here counts
315
+ * one note still on the pre-`data:` spelling, so the sweep has something to
316
+ * count down instead of a corpus nobody has surveyed.
317
+ *
318
+ * The `from` tag already carries the whole answer — step 3b is the only thing
319
+ * that produces it, and it produces it only for a `data.` source — so this is a
320
+ * name for the question rather than a second test of it. Named all the same,
321
+ * because a compile-time report and the frontmatter lint both ask it and must
322
+ * not drift apart about what counts.
323
+ *
324
+ * @param {FieldSpec} field - The declaration.
325
+ * @param {import("./system-block.mjs").FieldSource} from - Where
326
+ * {@link resolveFieldValue} said the value came from.
327
+ * @returns {boolean} True when the value came from the retiring top-level key.
328
+ */
329
+ export function readsRetiredTopLevel(field, from) {
330
+ return from === "topLevel" && retiredTopLevelKey(field) !== undefined;
331
+ }
332
+
231
333
  /**
232
334
  * Read one declared field out of a note's frontmatter.
233
335
  *
@@ -249,13 +351,77 @@ export function readsLegacyKey(field, from) {
249
351
  * rather than a returned list because the caller is a compiler, which already
250
352
  * knows the note and how to locate a key in it; this module knows neither and
251
353
  * would have to invent a finding shape to say so.
354
+ * @param {(field: FieldSpec) => void} [options.onRetiredTopLevel] - Called with
355
+ * each field read from the top-level key `data:` gathered it off (#332). The
356
+ * shared level's counterpart to `onLegacyKey`, and a separate callback
357
+ * because it is a separate position: a note may have moved one of the two and
358
+ * not the other, and a caller that conflated them would tell its author to
359
+ * fix the wrong line.
252
360
  * @returns {any} The value to emit.
253
361
  */
254
- export function readField(field, fm, { block = "sohl", onLegacyKey } = {}) {
362
+ export function readField(field, fm, options = {}) {
363
+ return readFieldEntry(field, fm, options).value;
364
+ }
365
+
366
+ /**
367
+ * The same read, reporting **where the value came from** as well.
368
+ *
369
+ * {@link readField} answers "what does this field hold", which is what almost
370
+ * every caller wants. A builder has one further question — *should the key be
371
+ * written at all* — and it cannot be answered from the value: `null` from a
372
+ * note and `null` from a declared default are the same value and opposite
373
+ * facts (#329).
374
+ *
375
+ * So the position rides back beside the value, resolved **once**. The
376
+ * alternative is a builder that calls {@link resolveFieldValue} for the source
377
+ * and {@link readField} for the value, which resolves the position twice and
378
+ * states in two places the rule that a field is authored in exactly one.
379
+ *
380
+ * @param {FieldSpec} field - The declaration.
381
+ * @param {object} fm - The note's frontmatter.
382
+ * @param {object} [options] - Options, as {@link readField} takes them.
383
+ * @param {string} [options.block="sohl"] - Which system's block to resolve
384
+ * against.
385
+ * @param {(field: FieldSpec) => void} [options.onLegacyKey] - See
386
+ * {@link readField}.
387
+ * @param {(field: FieldSpec) => void} [options.onRetiredTopLevel] - See
388
+ * {@link readField}.
389
+ * @returns {{value: any, from: import("./system-block.mjs").FieldSource}} The
390
+ * value to emit, and the position it was read from.
391
+ */
392
+ export function readFieldEntry(field, fm, { block = "sohl", onLegacyKey, onRetiredTopLevel } = {}) {
255
393
  const { value, from } = resolveFieldValue(field, fm, { block });
256
- if (from === "value") return value;
394
+ if (from === "value") return { value, from };
257
395
  if (onLegacyKey && readsLegacyKey(field, from)) onLegacyKey(field);
258
- return field.read ? field.read(value, { fm, field }) : value;
396
+ if (onRetiredTopLevel && readsRetiredTopLevel(field, from)) onRetiredTopLevel(field);
397
+ return { value: field.read ? field.read(value, { fm, field }) : value, from };
398
+ }
399
+
400
+ /**
401
+ * Whether a note supplied a value for a field, as opposed to a default doing it.
402
+ *
403
+ * The question {@link FieldSpec.omitWhenAbsent} turns on, asked of the
404
+ * *position* rather than of the value — which cannot answer it, since a
405
+ * declared `default: null` and an authored `null` are indistinguishable once
406
+ * the value is in hand (#329).
407
+ *
408
+ * `undefined` counts as absent whatever position reported it, because writing
409
+ * the key then emits a value `JSON.stringify` drops — the key present in the
410
+ * object and absent from the pack, which is the sort of disagreement this
411
+ * package exists to remove. It arrives from one place: the in-block step
412
+ * answers `value ?? field.default` for a key authored as `null`, so a field
413
+ * declaring no default resolves through "authored" to nothing at all. A field
414
+ * that also declares a `read` never reaches this, since its coercion has by
415
+ * then turned the `undefined` into whatever it makes of an absent value —
416
+ * ordinarily `null`, which is a value the note asked for and is emitted.
417
+ *
418
+ * @param {import("./system-block.mjs").FieldSource} from - Where
419
+ * {@link resolveFieldValue} said the value came from.
420
+ * @param {any} value - The value it gave back.
421
+ * @returns {boolean} True when the note wrote one.
422
+ */
423
+ export function isAuthored(from, value) {
424
+ return from !== "default" && value !== undefined;
259
425
  }
260
426
 
261
427
  /**
@@ -269,13 +435,37 @@ export function readField(field, fm, { block = "sohl", onLegacyKey } = {}) {
269
435
  * @param {(field: FieldSpec) => void} [options.onLegacyKey] - Passed through to
270
436
  * {@link readField}: called with each field the note authored at the position
271
437
  * it is being swept off (#305).
438
+ * @param {(field: FieldSpec) => void} [options.onRetiredTopLevel] - Passed
439
+ * through to {@link readField}: called with each field the note authored at
440
+ * the top-level key `data:` gathered it off (#332).
272
441
  * @returns {(fm: object) => object} A `system`-block builder.
273
442
  */
274
- export function buildFromFields(fields, { block = "sohl", onLegacyKey } = {}) {
443
+ export function buildFromFields(fields, { block = "sohl", onLegacyKey, onRetiredTopLevel } = {}) {
275
444
  return function buildDeclaredSystem(fm) {
276
445
  const out = {};
277
446
  for (const field of fields) {
278
- setPath(out, field.to, readField(field, fm, { block, onLegacyKey }));
447
+ // A runtime-only field is not this builder's to write (#330). It is
448
+ // declared so that the path is *claimed* — so the verbatim
449
+ // passthrough leaves it alone and the refusal has a name — not so
450
+ // that a compile-time answer is invented for a question only play
451
+ // can answer. Omitting the key is what leaves the DataModel's own
452
+ // `initial` standing; writing the `undefined` a source-less
453
+ // declaration resolves to would put the key in the document.
454
+ if (field.runtimeOnly) continue;
455
+ const { value, from } = readFieldEntry(field, fm, {
456
+ block,
457
+ onLegacyKey,
458
+ onRetiredTopLevel,
459
+ });
460
+ // The other conditional row: a field whose *absence* is meaningful
461
+ // (#329). Writing a declared default would answer a question the
462
+ // note did not ask — "this affliction's onset takes `null` days" —
463
+ // and would make the field's unset state indistinguishable from an
464
+ // authored one for every reader downstream. Omitting the key leaves
465
+ // the DataModel's own `initial` to say it instead, which is the one
466
+ // place the answer actually lives.
467
+ if (field.omitWhenAbsent && !isAuthored(from, value)) continue;
468
+ setPath(out, field.to, value);
279
469
  }
280
470
  return out;
281
471
  };
@@ -295,3 +485,20 @@ export function buildFromFields(fields, { block = "sohl", onLegacyKey } = {}) {
295
485
  export function authoredFields(fields) {
296
486
  return fields.filter((field) => field.name !== undefined);
297
487
  }
488
+
489
+ /**
490
+ * The fields of a declaration a note may **never** write.
491
+ *
492
+ * The complement of {@link authoredFields} in the direction that matters: those
493
+ * are the fields an author may write, these are the ones authoring is an error
494
+ * (#330). Everything else in a declaration — a constant, a derived value — is
495
+ * simply not authored, which is a statement about the *builder* rather than
496
+ * about the author, and says nothing about what happens if a note writes the
497
+ * path anyway.
498
+ *
499
+ * @param {readonly FieldSpec[]} fields - The declaration.
500
+ * @returns {FieldSpec[]} Only the fields declaring `runtimeOnly`.
501
+ */
502
+ export function runtimeOnlyFields(fields) {
503
+ return (fields ?? []).filter((field) => Boolean(field?.runtimeOnly));
504
+ }
@@ -185,6 +185,29 @@ export function folderAddress(pkg, shortcode) {
185
185
  return canonicalKey(pkg, NO_SYSTEM, FOLDER_TYPE, shortcode);
186
186
  }
187
187
 
188
+ /**
189
+ * The Foundry `_id` a folder note's documents are filed under.
190
+ *
191
+ * **The one derivation, because two passes need the same answer.** The folder
192
+ * pass hashes it here on its way to emitting the `Folder` documents; the
193
+ * content index publishes it, through `noteDocId`, for a consumer who will
194
+ * never run this build. Deriving it twice is how the index came to publish a
195
+ * plausible-looking id that resolved to nothing — the general id rule hashes a
196
+ * note's address under the `document` namespace, and a folder's is hashed under
197
+ * {@link FOLDER_ID_NAMESPACE} (#310).
198
+ *
199
+ * An **authored `id` still wins**, and is applied by the caller: this is the
200
+ * derivation, not the resolution, so the pin rule stays stated once, where
201
+ * every other note type states it ({@link module:engine/note-ids.noteDocId}).
202
+ *
203
+ * @param {string} pkg - The content package.
204
+ * @param {string} shortcode - The folder's shortcode.
205
+ * @returns {string} The folder's 16-character Foundry id.
206
+ */
207
+ export function folderDocId(pkg, shortcode) {
208
+ return makeId(FOLDER_ID_NAMESPACE, folderAddress(pkg, shortcode));
209
+ }
210
+
188
211
  /**
189
212
  * Collect every folder note in a content tree.
190
213
  *
@@ -246,7 +269,7 @@ export function collectFolderNotes(notes, pkg) {
246
269
  // stable one from its address (#258). Keeping the authored id is
247
270
  // what makes this a build change rather than a world migration: a
248
271
  // world already holding these folders goes on resolving them.
249
- id: authoredId || makeId(FOLDER_ID_NAMESPACE, address),
272
+ id: authoredId || folderDocId(pkg, shortcode),
250
273
  derivedId: !authoredId,
251
274
  absPath,
252
275
  });
@@ -1,3 +1,16 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
1
14
  /**
2
15
  * @file The item catalogue of a package this repository depends on but does not
3
16
  * contain.
@@ -151,13 +164,73 @@ function cacheSchemaArtifact(root, dir) {
151
164
  return true;
152
165
  }
153
166
 
167
+ /**
168
+ * What each extracted pack is, written beside the items rather than inferred
169
+ * from the directory it landed in (#58).
170
+ *
171
+ * A dependency may ship a pack per system — `items-sohl` and `items-hm3` — and
172
+ * the two hold documents of the *same* `(type, shortcode)` addresses with
173
+ * different data models: `skill:awar` exists in both vocabularies and means two
174
+ * different documents. So a consumer compiling an `hm3` pack has to read the
175
+ * `hm3` half of the catalogue and no other, and the only place that says which
176
+ * half a directory is, is the dependency's own manifest at fetch time.
177
+ *
178
+ * Held as a manifest at the cache root rather than a marker inside each pack
179
+ * directory, because those directories are walked as JSON trees: a file dropped
180
+ * in one would be loaded as though it were a document.
181
+ *
182
+ * @type {string}
183
+ */
184
+ const ITEM_PACKS = "item-packs.json";
185
+
186
+ /**
187
+ * What to record about the packs being extracted.
188
+ *
189
+ * Exported so the pair is one fact: {@link foreignItemCatalogDirs} reads what
190
+ * this writes, and a test that hand-wrote the file would prove the reader
191
+ * against a transcription of the format rather than against the format.
192
+ *
193
+ * A pack declaring no `system` records `null` — Foundry requires the field on
194
+ * an Item pack, so this is the shape of a manifest that is wrong rather than a
195
+ * case with a meaning, and `null` reads as "neutral", which is the safe way to
196
+ * be wrong: a neutral pack is read by every system rather than by none.
197
+ *
198
+ * @param {readonly object[]} itemPacks - The manifest's Item pack entries.
199
+ * @returns {Array<{name: string, system: string|null}>} What each one is.
200
+ */
201
+ export function itemPackManifest(itemPacks) {
202
+ return itemPacks.map((pack) => ({ name: pack.name, system: pack.system ?? null }));
203
+ }
204
+
205
+ /**
206
+ * The system each extracted pack was published for.
207
+ *
208
+ * @param {string} dir - The dependency's cache directory.
209
+ * @returns {Map<string, string|null>} Pack name → its declared system, `null`
210
+ * for a pack that declares none.
211
+ */
212
+ function cachedItemPacks(dir) {
213
+ const file = path.join(dir, ITEM_PACKS);
214
+ const declared = JSON.parse(fs.readFileSync(file, "utf8"));
215
+ return new Map(declared.map((pack) => [pack.name, pack.system ?? null]));
216
+ }
217
+
154
218
  /**
155
219
  * Whether a dependency's cache is present and complete.
156
220
  *
221
+ * **A cache without its pack manifest is incomplete**, not merely unlabelled.
222
+ * One written before #58 holds the items and not what they are, and the two
223
+ * ways of proceeding without it are both wrong: reading every pack resolves an
224
+ * `hm3` reference against `sohl` documents — the silent-wrong-output failure
225
+ * this scoping exists to remove — and reading none fails a build that was
226
+ * working. Treating it as incomplete makes `content-build deps fetch` refill
227
+ * it, which is a command the cold-cache path already tells anyone to run.
228
+ *
157
229
  * @param {string} dir - The dependency's cache directory.
158
230
  * @returns {boolean} True when it was fetched to completion.
159
231
  */
160
- const isComplete = (dir) => fs.existsSync(path.join(dir, STAMP));
232
+ const isComplete = (dir) =>
233
+ fs.existsSync(path.join(dir, STAMP)) && fs.existsSync(path.join(dir, ITEM_PACKS));
161
234
 
162
235
  /**
163
236
  * Read a dependency's manifest.
@@ -230,8 +303,18 @@ async function extractItemPacks(id, version, manifest, root, dir) {
230
303
  const out = path.join(dir, "items", pack.name);
231
304
  fs.mkdirSync(out, { recursive: true });
232
305
  await extractPack(src, out, { log: false });
233
- log.info(`${id}@${version}: extracted pack "${pack.name}"`);
306
+ log.info(
307
+ `${id}@${version}: extracted pack "${pack.name}"` +
308
+ (pack.system ? ` (system: ${pack.system})` : ""),
309
+ );
234
310
  }
311
+ // What each pack is, from the only place that knows: the manifest that
312
+ // declared it (#58). Written before the stamp, so the stamp continues to
313
+ // mean the cache is whole.
314
+ fs.writeFileSync(
315
+ path.join(dir, ITEM_PACKS),
316
+ `${JSON.stringify(itemPackManifest(itemPacks), null, 4)}\n`,
317
+ );
235
318
  // Last, so a fetch that died partway is never mistaken for a complete one.
236
319
  fs.writeFileSync(path.join(dir, STAMP), `${version}\n`);
237
320
  }
@@ -637,10 +720,24 @@ export async function fetchAllCatalogs(config) {
637
720
  * Reads the cache only. A cold cache is an error naming the command that fills
638
721
  * it, rather than a download nobody asked for.
639
722
  *
723
+ * **Scoped to one system when the caller compiles for one (#58)**, exactly as
724
+ * {@link module:engine/generate.itemPackJsonDirs} scopes the local half. The
725
+ * two halves answer the same lookup — `loadItemsMap` merges them into one
726
+ * address space keyed by `subType:shortcode` — so scoping only the local one
727
+ * leaves the collision it was meant to remove: `skill:awar` is a real address
728
+ * in both vocabularies, and a `harn-ensemble` actor compiled for `hm3` would
729
+ * resolve three quarters of its references against whichever document the
730
+ * dependency's `sohl` pack happened to supply. A pack that declares no system
731
+ * is neutral and always read; asking for no system reads every pack, which is
732
+ * every single-system build.
733
+ *
640
734
  * @param {object} config - The resolved build configuration.
641
- * @returns {string[]} Every cached dependency's item directories.
735
+ * @param {string|null} [system] - The system the caller is compiling for.
736
+ * Omitted or `null`, every cached pack is read.
737
+ * @returns {Array<{dir: string, package: string}>} Every cached dependency's
738
+ * item directories, each with the package that published it.
642
739
  */
643
- export function foreignItemCatalogDirs(config) {
740
+ export function foreignItemCatalogDirs(config, system = null) {
644
741
  const dirs = [];
645
742
  for (const rel of itemCatalogRelationships(config)) {
646
743
  const root = config.paths.foreignCache;
@@ -664,9 +761,17 @@ export function foreignItemCatalogDirs(config) {
664
761
  // rewritten: a plain string sort would put `0.8.10` before `0.8.2` and
665
762
  // silently resolve every embedded item against the older catalogue
666
763
  // (#272).
667
- const items = itemsDir(newestVersionDir(cached));
668
- for (const name of fs.readdirSync(items)) {
669
- dirs.push(path.join(items, name));
764
+ const newest = newestVersionDir(cached);
765
+ const packSystems = cachedItemPacks(newest);
766
+ const items = itemsDir(newest);
767
+ for (const entry of fs.readdirSync(items, { withFileTypes: true })) {
768
+ if (!entry.isDirectory()) continue;
769
+ const packSystem = packSystems.get(entry.name) ?? null;
770
+ if (system != null && packSystem != null && packSystem !== system) continue;
771
+ // The dependency's own id travels with its directory (#334): a
772
+ // being's `model:` names the package its template comes from, and
773
+ // the address cannot be built from the path.
774
+ dirs.push({ dir: path.join(items, entry.name), package: rel.id });
670
775
  }
671
776
  }
672
777
  return dirs;
@@ -133,6 +133,12 @@ export function anchorsOf(entryUuid, entryId, body, name) {
133
133
  * @param {string} name - The note's display name.
134
134
  * @param {string} address - The note's package-relative address.
135
135
  * @param {string} body - The note's markdown body.
136
+ * **Each entry carries the `id` of the document it addresses**, not only its
137
+ * UUID. The two are one fact — a UUID ends in the id — but only the entry knows
138
+ * which derivation produced it: an item's is its note's `fm.id`, and its
139
+ * documentation journal's is {@link itemDocEntryId} of that. Stating it here is
140
+ * what lets the content index publish an identity it did not re-derive (#310).
141
+ *
136
142
  * @param {object} ctx - Resolved identities: `{ contentPackage,
137
143
  * foundryPackageId, packRouter }`.
138
144
  * @returns {Array<object>} One or two entries, in {@link buildManifest}'s shape.
@@ -201,6 +207,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
201
207
  fm,
202
208
  name,
203
209
  url,
210
+ id: fm.id,
204
211
  uuid: uuidFor(fm.type, fm.id, fm),
205
212
  doc: docKey,
206
213
  },
@@ -211,6 +218,12 @@ export function entriesForNote(fm, name, address, body, ctx) {
211
218
  // On the web the item note renders as one page which *is* its
212
219
  // documentation, so both addresses resolve to the same URL.
213
220
  url,
221
+ // The journal's **own** id, which is not the item's: the
222
+ // content index publishes it beside the UUID, so an entry the
223
+ // index gives an identity to states both halves of it rather
224
+ // than leaving a consumer to parse the id back out of the
225
+ // UUID's last segment (#310).
226
+ id: docEntryId,
214
227
  uuid: docUuid,
215
228
  anchors: docUuid ? anchorsOf(docUuid, docEntryId, body ?? "", name) : undefined,
216
229
  },
@@ -226,6 +239,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
226
239
  fm,
227
240
  name,
228
241
  url,
242
+ id: fm.id,
229
243
  uuid: own,
230
244
  anchors: own && fm.type === "doc" ? anchorsOf(own, fm.id, body ?? "", name) : undefined,
231
245
  },