@heroiclands/package-build 19.0.0 → 20.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 (65) hide show
  1. package/CHANGELOG.md +624 -0
  2. package/CONTENT.md +79 -8
  3. package/bin/content-build.mjs +7 -1
  4. package/content-config.mjs +10 -1
  5. package/docs/content-format.md +394 -72
  6. package/engine/actor-compiler.mjs +197 -7
  7. package/engine/address-charset.mjs +23 -5
  8. package/engine/base-compiler.mjs +63 -2
  9. package/engine/bundles.mjs +9 -0
  10. package/engine/content-address.mjs +92 -1
  11. package/engine/content-format.mjs +102 -0
  12. package/engine/content-index.mjs +11 -8
  13. package/engine/content-links.mjs +37 -21
  14. package/engine/field-reference.mjs +57 -5
  15. package/engine/field-spec.mjs +214 -7
  16. package/engine/folder-notes.mjs +24 -1
  17. package/engine/foreign-catalog.mjs +4 -1
  18. package/engine/foundry-entries.mjs +14 -0
  19. package/engine/frontmatter-lint.mjs +186 -27
  20. package/engine/frontmatter.mjs +11 -11
  21. package/engine/generate.mjs +67 -10
  22. package/engine/helpers.mjs +86 -9
  23. package/engine/index.mjs +3 -0
  24. package/engine/item-compiler.mjs +37 -0
  25. package/engine/journals.mjs +21 -4
  26. package/engine/macros.mjs +8 -0
  27. package/engine/map-notes.mjs +7 -7
  28. package/engine/note-ids.mjs +25 -1
  29. package/engine/note-vocabulary.mjs +76 -9
  30. package/engine/retired-fields.mjs +57 -16
  31. package/engine/runtime-only-fields.mjs +204 -0
  32. package/engine/scenes.mjs +12 -19
  33. package/engine/schema-check.mjs +23 -1
  34. package/engine/site-index.mjs +17 -0
  35. package/engine/subtype-registry.mjs +30 -0
  36. package/engine/system-block.mjs +81 -3
  37. package/engine/web-wikilinks.mjs +33 -27
  38. package/engine/wikilink-syntax.mjs +7 -0
  39. package/engine/wikilinks.mjs +74 -16
  40. package/hm3/actors.mjs +63 -13
  41. package/package.json +2 -2
  42. package/sohl/actors.mjs +106 -7
  43. package/sohl/item-fields.mjs +203 -0
  44. package/sohl/note-schemas.mjs +6 -3
  45. package/types/engine/actor-compiler.d.mts +83 -3
  46. package/types/engine/address-charset.d.mts +22 -4
  47. package/types/engine/base-compiler.d.mts +54 -3
  48. package/types/engine/content-address.d.mts +64 -0
  49. package/types/engine/content-format.d.mts +9 -0
  50. package/types/engine/field-spec.d.mts +271 -3
  51. package/types/engine/folder-notes.d.mts +20 -0
  52. package/types/engine/foundry-entries.d.mts +6 -0
  53. package/types/engine/frontmatter-lint.d.mts +18 -2
  54. package/types/engine/frontmatter.d.mts +11 -11
  55. package/types/engine/generate.d.mts +27 -0
  56. package/types/engine/helpers.d.mts +37 -9
  57. package/types/engine/index.d.mts +1 -0
  58. package/types/engine/map-notes.d.mts +2 -2
  59. package/types/engine/note-ids.d.mts +14 -0
  60. package/types/engine/retired-fields.d.mts +29 -13
  61. package/types/engine/runtime-only-fields.d.mts +102 -0
  62. package/types/engine/schema-check.d.mts +10 -1
  63. package/types/engine/subtype-registry.d.mts +21 -0
  64. package/types/engine/system-block.d.mts +28 -2
  65. package/types/sohl/actors.d.mts +3 -3
@@ -490,9 +490,9 @@ export function buildIndexRecord({
490
490
  * **Lean, and deliberately not the note's frontmatter.** The item's `sohl:`
491
491
  * block describes the *item*; copying it onto the journal would assert things
492
492
  * about the journal that are not true, and double the file to do it. What the
493
- * journal has of its own is its addresses, its name, and the file it came from
494
- * — plus `documents`, naming the record it is the documentation for, so the
495
- * link is navigable in both directions.
493
+ * journal has of its own is its addresses, its **id**, its name, and the file
494
+ * it came from — plus `documents`, naming the record it is the documentation
495
+ * for, so the link is navigable in both directions.
496
496
  *
497
497
  * On the web both addresses resolve to one page — the item note renders as the
498
498
  * page that *is* its documentation — so the slug is shared and only the
@@ -514,6 +514,11 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
514
514
  type: `doc${frontmatter.type}`,
515
515
  shortcode: frontmatter.shortcode,
516
516
  name: frontmatter.name,
517
+ // The journal's own `_id`, taken from the entry rather than
518
+ // re-derived: every entry the index gives an identity to publishes
519
+ // both the id and the UUID, computed once by whatever owns that
520
+ // entry's derivation (#310).
521
+ id: entry.id,
517
522
  nameAscii: asciiName(frontmatter?.name?.full),
518
523
  address: { slug: address.slug, canonical: entry.key },
519
524
  // The record this is the documentation *for*. `documentation` is
@@ -595,15 +600,13 @@ export function collectContentIndex(
595
600
  }
596
601
  }
597
602
 
598
- // Content path, then the note id. The walk yields in directory-read order,
599
- // which is not a fact about the content, and a rebuild that reorders lines
600
- // would make every regeneration look like a change.
601
603
  // Content path, then the canonical address, then the note id. The walk
602
604
  // yields in directory-read order, which is not a fact about the content,
603
605
  // and a rebuild that reordered lines would make every regeneration look
604
606
  // like a change. The address comes before the id because an item note's two
605
- // records share a file and only one of them carries an id — ordering on the
606
- // id first would put the documentation ahead of the item it documents.
607
+ // records share a file and carry two different ids — ordering on the id
608
+ // first would sort the documentation against the item it documents by a
609
+ // pair of hashes, which is no order at all.
607
610
  records.sort(
608
611
  (a, b) =>
609
612
  String(a.file.path).localeCompare(String(b.file.path), "en") ||
@@ -70,7 +70,13 @@ import { NO_SYSTEM, systemOf } from "./document-subtypes.mjs";
70
70
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
71
71
  import { loadPackConfig } from "./pack-config.mjs";
72
72
  import { searchableFrontmatter } from "./note-package.mjs";
73
- import { canonicalKey, PACKAGE_BASE, readCanonicalKey } from "./content-address.mjs";
73
+ import {
74
+ blockSystem,
75
+ canonicalKey,
76
+ expandAddress,
77
+ PACKAGE_BASE,
78
+ readCanonicalKey,
79
+ } from "./content-address.mjs";
74
80
  import { loadForeignIndexes } from "./metadata-index.mjs";
75
81
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
76
82
  import { homepageAddresses, isHomepage } from "./homepage.mjs";
@@ -195,9 +201,14 @@ export function buildLinkIndex(
195
201
  anchors.set(note, new Set((record.anchors ?? []).map((a) => a.slug)));
196
202
 
197
203
  if (typeof fm.shortcode === "string" && fm.shortcode) {
198
- byKey.set(`${type}/${fm.shortcode}`.toLowerCase(), note);
199
- // The canonical, fully qualified address alongside the short one,
200
- // so a package-qualified link checks the same way a bare one does.
204
+ // Canonical addresses only. Every written target expands to one
205
+ // before lookup (#336), so there is nothing left for a short key to
206
+ // answer and the short key was harmful: `type/shortcode` is
207
+ // system-blind, set with a plain `Map.set`, so two notes in one
208
+ // package sharing a `(type, shortcode)` across systems silently
209
+ // overwrote each other in that slot while both canonical keys sat
210
+ // correctly beside it.
211
+ //
201
212
  // Taken from the record, which is where the address rule is applied
202
213
  // once for the whole build.
203
214
  const canonical =
@@ -205,7 +216,6 @@ export function buildLinkIndex(
205
216
  canonicalKey(pkg, systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS), type, fm.shortcode);
206
217
  byKey.set(canonical, note);
207
218
  if (hasDocEntry(type)) {
208
- byKey.set(`doc${type}/${fm.shortcode}`.toLowerCase(), note);
209
219
  // A documentation journal is `none`: no game system defines a
210
220
  // JournalEntry, and one note has one of them however many
211
221
  // system blocks it carries.
@@ -341,23 +351,19 @@ export function buildLinkIndex(
341
351
  return matchAddress([...byKey], q).map(([, v]) => v);
342
352
  }
343
353
 
344
- function resolveAddress(target) {
354
+ function resolveAddress(target, keyPath) {
345
355
  const qualified = readQualifier(target, types, packages);
346
356
  if (!qualified || qualified.reason) return undefined;
347
- // A target naming neither package nor system stays the system-blind
348
- // short key, which is already the wildcard an author writing
349
- // `[[skill-melee]]` means.
357
+ // Every omitted segment defaults from where the link is written (#336),
358
+ // so the target expands to exactly one canonical address and this is a
359
+ // plain lookup. There is no candidate set, and therefore no single-hit
360
+ // rule and no ambiguity to report.
350
361
  //
351
- // Anything that *does* state one is matched by the segments it supplied
352
- // rather than by an exact key (#59), and exactly one hit is required —
353
- // two systems' documents legitimately share a
354
- // `(package, type, shortcode)`, so a target that names no system may
355
- // name two notes, and naming two is not resolving.
356
- if (!qualified.package && !qualified.system) {
357
- return byKey.get(`${qualified.type}/${qualified.shortcode}`.toLowerCase());
358
- }
359
- const hits = matchLocal(qualified);
360
- return hits.length === 1 ? hits[0] : undefined;
362
+ // It replaced a system-blind short key, `type/shortcode`, populated by
363
+ // plain `Map.set` so two notes in one package sharing a
364
+ // `(type, shortcode)` across systems silently overwrote each other, and
365
+ // a bare link resolved to whichever was indexed second.
366
+ return byKey.get(expandAddress(qualified, { package: pkg, system: blockSystem(keyPath) }));
361
367
  }
362
368
 
363
369
  /**
@@ -375,10 +381,20 @@ export function buildLinkIndex(
375
381
  * @param {string} target - The link target.
376
382
  * @returns {object[]} The foreign entries, each carrying its `package`.
377
383
  */
378
- function foreignHits(target) {
384
+ function foreignHits(target, keyPath) {
379
385
  const q = readQualifier(target, types, packages);
380
386
  if (!q || q.reason) return [];
381
- return matchAddress([...foreign.index], q).map(([, v]) => v);
387
+ // An omitted package means *this* package (#336), so a short form
388
+ // addresses nothing foreign and never reaches a dependency's index.
389
+ // Reaching another package is the fully qualified form's job, and
390
+ // saying so is the whole point: a link that resolved into `sohl` only
391
+ // because no local note claimed the address was resolving by accident,
392
+ // and would have retargeted silently the day one did.
393
+ if (!q.package) return [];
394
+ const hit = foreign.index.get(
395
+ expandAddress(q, { package: q.package, system: blockSystem(keyPath) }),
396
+ );
397
+ return hit ? [hit] : [];
382
398
  }
383
399
 
384
400
  /**
@@ -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
  });
@@ -666,7 +666,10 @@ export function foreignItemCatalogDirs(config) {
666
666
  // (#272).
667
667
  const items = itemsDir(newestVersionDir(cached));
668
668
  for (const name of fs.readdirSync(items)) {
669
- dirs.push(path.join(items, name));
669
+ // The dependency's own id travels with its directory (#334): a
670
+ // being's `model:` names the package its template comes from, and
671
+ // the address cannot be built from the path.
672
+ dirs.push({ dir: path.join(items, name), package: rel.id });
670
673
  }
671
674
  }
672
675
  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
  },