@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
@@ -113,6 +113,70 @@ export function packageAddress(fm: object): string;
113
113
  * @returns {string} `package-system-type-shortcode`, lowercased.
114
114
  */
115
115
  export function canonicalKey(pkg: string, system: string, type: string, shortcode: string): string;
116
+ /**
117
+ * Which system a frontmatter key path is written under.
118
+ *
119
+ * The **enclosing system block** decides, at any depth within it, and nothing
120
+ * else does: `sohl.items[3].model` and `sohl.system.body.structure` are both
121
+ * `sohl` because both sit under `sohl:`. Everywhere else is {@link NO_SYSTEM} —
122
+ * top-level frontmatter, the shared `data:` container, and body prose, which has
123
+ * no key path at all and passes `undefined`.
124
+ *
125
+ * It is the block rather than the field, so a `WikiLink` field needs no opinion
126
+ * about systems and no per-field table has to be kept in step with the schema.
127
+ *
128
+ * The first segment must **be** a declared system, not merely look like one:
129
+ * `sohlish.items` is a key called `sohlish`, and `notes.sohl.thing` names no
130
+ * block at all.
131
+ *
132
+ * @param {string} [keyPath] - The dotted frontmatter key path, or `undefined`
133
+ * for body prose.
134
+ * @returns {string} The system id, or `none`.
135
+ */
136
+ export function blockSystem(keyPath?: string): string;
137
+ /**
138
+ * Expand a written address to the one canonical address it names.
139
+ *
140
+ * **An omitted segment defaults from where the link is written** (#336) — it is
141
+ * not a wildcard, and resolution is not a search. Package omitted means the
142
+ * citing note's own; system omitted means {@link blockSystem} of the key path it
143
+ * was written under. So every short form has exactly one expansion, computed
144
+ * before anything is looked up, and there is no candidate set to disambiguate.
145
+ *
146
+ * **Under `none`, a system-bearing type addresses its documentation journal.**
147
+ * A note's `none` address *is* its `doc<type>` entry — the Item is the one with
148
+ * a system — so a prose `[[affiliation-sirvadar|…]]` names the page, which is
149
+ * almost always what prose means. A link that means the Item states the system
150
+ * and gets it. This is the defaulting rule applied, not an exception carved out
151
+ * of it.
152
+ *
153
+ * **Only a type whose own document carries a system is redirected.** A `macro`
154
+ * and the map types have documentation journals too, but their own documents
155
+ * are core ones and already live at `none` — so `<pkg>-none-macro-x` names the
156
+ * Macro and `<pkg>-none-docmacro-x` its journal, two live addresses that the
157
+ * redirect would collapse into one. The test is the note type's own system,
158
+ * not merely whether it has a doc entry.
159
+ *
160
+ * A `doc<type>` written explicitly is `none` **wherever** it appears, even
161
+ * inside a system block: no game system defines a JournalEntry, so there is no
162
+ * other system for one to belong to.
163
+ *
164
+ * @param {{type: string, shortcode: string, package?: string, system?: string,
165
+ * itemDoc?: boolean}} read - A qualifier, as `readQualifier` returns one.
166
+ * @param {{package: string, system?: string}} where - The citing context: the
167
+ * tree's own content package, and the system of the block the link sits in.
168
+ * @returns {string} The canonical `package-system-type-shortcode`.
169
+ */
170
+ export function expandAddress(read: {
171
+ type: string;
172
+ shortcode: string;
173
+ package?: string;
174
+ system?: string;
175
+ itemDoc?: boolean;
176
+ }, where: {
177
+ package: string;
178
+ system?: string;
179
+ }): string;
116
180
  /**
117
181
  * Reads a canonical key back into its parts.
118
182
  *
@@ -40,6 +40,9 @@ export const CONTENT_FORMAT_PATH: string;
40
40
  * property — what a note actually writes. `appearance.eye_color` is authored
41
41
  * as `appearance`, so that is the key recorded.
42
42
  * @property {Set<string>} dataPaths - The declared paths, whole.
43
+ * @property {string[]} subTypes - The `subType` values the section enumerates,
44
+ * in document order — empty when it states none, which is the ordinary case
45
+ * for a type that has no `subType` at all.
43
46
  */
44
47
  /**
45
48
  * One `system.*` target the specification names for one note type.
@@ -99,6 +102,12 @@ export type TypeSpec = {
99
102
  * - The declared paths, whole.
100
103
  */
101
104
  dataPaths: Set<string>;
105
+ /**
106
+ * - The `subType` values the section enumerates,
107
+ * in document order — empty when it states none, which is the ordinary case
108
+ * for a type that has no `subType` at all.
109
+ */
110
+ subTypes: string[];
102
111
  };
103
112
  /**
104
113
  * One `system.*` target the specification names for one note type.
@@ -17,6 +17,26 @@
17
17
  * @returns {boolean} True when the value came from the retiring position.
18
18
  */
19
19
  export function readsLegacyKey(field: FieldSpec, from: import("./system-block.mjs").FieldSource): boolean;
20
+ /**
21
+ * Whether a resolution read a field from the top-level key `data:` gathered it
22
+ * off — the shared level's retiring position (#332).
23
+ *
24
+ * {@link readsLegacyKey}'s sibling, and the same signal: a finding here counts
25
+ * one note still on the pre-`data:` spelling, so the sweep has something to
26
+ * count down instead of a corpus nobody has surveyed.
27
+ *
28
+ * The `from` tag already carries the whole answer — step 3b is the only thing
29
+ * that produces it, and it produces it only for a `data.` source — so this is a
30
+ * name for the question rather than a second test of it. Named all the same,
31
+ * because a compile-time report and the frontmatter lint both ask it and must
32
+ * not drift apart about what counts.
33
+ *
34
+ * @param {FieldSpec} field - The declaration.
35
+ * @param {import("./system-block.mjs").FieldSource} from - Where
36
+ * {@link resolveFieldValue} said the value came from.
37
+ * @returns {boolean} True when the value came from the retiring top-level key.
38
+ */
39
+ export function readsRetiredTopLevel(field: FieldSpec, from: import("./system-block.mjs").FieldSource): boolean;
20
40
  /**
21
41
  * Read one declared field out of a note's frontmatter.
22
42
  *
@@ -38,12 +58,77 @@ export function readsLegacyKey(field: FieldSpec, from: import("./system-block.mj
38
58
  * rather than a returned list because the caller is a compiler, which already
39
59
  * knows the note and how to locate a key in it; this module knows neither and
40
60
  * would have to invent a finding shape to say so.
61
+ * @param {(field: FieldSpec) => void} [options.onRetiredTopLevel] - Called with
62
+ * each field read from the top-level key `data:` gathered it off (#332). The
63
+ * shared level's counterpart to `onLegacyKey`, and a separate callback
64
+ * because it is a separate position: a note may have moved one of the two and
65
+ * not the other, and a caller that conflated them would tell its author to
66
+ * fix the wrong line.
41
67
  * @returns {any} The value to emit.
42
68
  */
43
- export function readField(field: FieldSpec, fm: object, { block, onLegacyKey }?: {
69
+ export function readField(field: FieldSpec, fm: object, options?: {
44
70
  block?: string | undefined;
45
71
  onLegacyKey?: ((field: FieldSpec) => void) | undefined;
72
+ onRetiredTopLevel?: ((field: FieldSpec) => void) | undefined;
46
73
  }): any;
74
+ /**
75
+ * The same read, reporting **where the value came from** as well.
76
+ *
77
+ * {@link readField} answers "what does this field hold", which is what almost
78
+ * every caller wants. A builder has one further question — *should the key be
79
+ * written at all* — and it cannot be answered from the value: `null` from a
80
+ * note and `null` from a declared default are the same value and opposite
81
+ * facts (#329).
82
+ *
83
+ * So the position rides back beside the value, resolved **once**. The
84
+ * alternative is a builder that calls {@link resolveFieldValue} for the source
85
+ * and {@link readField} for the value, which resolves the position twice and
86
+ * states in two places the rule that a field is authored in exactly one.
87
+ *
88
+ * @param {FieldSpec} field - The declaration.
89
+ * @param {object} fm - The note's frontmatter.
90
+ * @param {object} [options] - Options, as {@link readField} takes them.
91
+ * @param {string} [options.block="sohl"] - Which system's block to resolve
92
+ * against.
93
+ * @param {(field: FieldSpec) => void} [options.onLegacyKey] - See
94
+ * {@link readField}.
95
+ * @param {(field: FieldSpec) => void} [options.onRetiredTopLevel] - See
96
+ * {@link readField}.
97
+ * @returns {{value: any, from: import("./system-block.mjs").FieldSource}} The
98
+ * value to emit, and the position it was read from.
99
+ */
100
+ export function readFieldEntry(field: FieldSpec, fm: object, { block, onLegacyKey, onRetiredTopLevel }?: {
101
+ block?: string | undefined;
102
+ onLegacyKey?: ((field: FieldSpec) => void) | undefined;
103
+ onRetiredTopLevel?: ((field: FieldSpec) => void) | undefined;
104
+ }): {
105
+ value: any;
106
+ from: import("./system-block.mjs").FieldSource;
107
+ };
108
+ /**
109
+ * Whether a note supplied a value for a field, as opposed to a default doing it.
110
+ *
111
+ * The question {@link FieldSpec.omitWhenAbsent} turns on, asked of the
112
+ * *position* rather than of the value — which cannot answer it, since a
113
+ * declared `default: null` and an authored `null` are indistinguishable once
114
+ * the value is in hand (#329).
115
+ *
116
+ * `undefined` counts as absent whatever position reported it, because writing
117
+ * the key then emits a value `JSON.stringify` drops — the key present in the
118
+ * object and absent from the pack, which is the sort of disagreement this
119
+ * package exists to remove. It arrives from one place: the in-block step
120
+ * answers `value ?? field.default` for a key authored as `null`, so a field
121
+ * declaring no default resolves through "authored" to nothing at all. A field
122
+ * that also declares a `read` never reaches this, since its coercion has by
123
+ * then turned the `undefined` into whatever it makes of an absent value —
124
+ * ordinarily `null`, which is a value the note asked for and is emitted.
125
+ *
126
+ * @param {import("./system-block.mjs").FieldSource} from - Where
127
+ * {@link resolveFieldValue} said the value came from.
128
+ * @param {any} value - The value it gave back.
129
+ * @returns {boolean} True when the note wrote one.
130
+ */
131
+ export function isAuthored(from: import("./system-block.mjs").FieldSource, value: any): boolean;
47
132
  /**
48
133
  * Turn a field declaration into the builder it declares.
49
134
  *
@@ -55,11 +140,15 @@ export function readField(field: FieldSpec, fm: object, { block, onLegacyKey }?:
55
140
  * @param {(field: FieldSpec) => void} [options.onLegacyKey] - Passed through to
56
141
  * {@link readField}: called with each field the note authored at the position
57
142
  * it is being swept off (#305).
143
+ * @param {(field: FieldSpec) => void} [options.onRetiredTopLevel] - Passed
144
+ * through to {@link readField}: called with each field the note authored at
145
+ * the top-level key `data:` gathered it off (#332).
58
146
  * @returns {(fm: object) => object} A `system`-block builder.
59
147
  */
60
- export function buildFromFields(fields: readonly FieldSpec[], { block, onLegacyKey }?: {
148
+ export function buildFromFields(fields: readonly FieldSpec[], { block, onLegacyKey, onRetiredTopLevel }?: {
61
149
  block?: string | undefined;
62
150
  onLegacyKey?: ((field: FieldSpec) => void) | undefined;
151
+ onRetiredTopLevel?: ((field: FieldSpec) => void) | undefined;
63
152
  }): (fm: object) => object;
64
153
  /**
65
154
  * The fields of a declaration an author actually writes.
@@ -73,6 +162,20 @@ export function buildFromFields(fields: readonly FieldSpec[], { block, onLegacyK
73
162
  * @returns {FieldSpec[]} Only the fields with a frontmatter `name`.
74
163
  */
75
164
  export function authoredFields(fields: readonly FieldSpec[]): FieldSpec[];
165
+ /**
166
+ * The fields of a declaration a note may **never** write.
167
+ *
168
+ * The complement of {@link authoredFields} in the direction that matters: those
169
+ * are the fields an author may write, these are the ones authoring is an error
170
+ * (#330). Everything else in a declaration — a constant, a derived value — is
171
+ * simply not authored, which is a statement about the *builder* rather than
172
+ * about the author, and says nothing about what happens if a note writes the
173
+ * path anyway.
174
+ *
175
+ * @param {readonly FieldSpec[]} fields - The declaration.
176
+ * @returns {FieldSpec[]} Only the fields declaring `runtimeOnly`.
177
+ */
178
+ export function runtimeOnlyFields(fields: readonly FieldSpec[]): FieldSpec[];
76
179
  /**
77
180
  * @typedef {object} FieldSpec
78
181
  * @property {string} to - Dotted path in the emitted `system` block — and,
@@ -135,6 +238,13 @@ export function authoredFields(fields: readonly FieldSpec[]): FieldSpec[];
135
238
  * describe the *document* rather than the note: `<system>.system.<to>` and
136
239
  * the legacy in-block `<system>.<name>`. Absent means the ordinary case —
137
240
  * the top level is read, as the third step.
241
+ *
242
+ * **It is read from the other side too**, because the statement is symmetric:
243
+ * if the two positions hold unrelated quantities then the *in-block* position
244
+ * is not the note-level field either, so a check about the note-level field
245
+ * reads past it. `engine/frontmatter-lint.mjs` resolves that through
246
+ * `collidingBlockKeys`. Read for the emitted field alone until #312, which is
247
+ * how an affiliation's office style came to answer for its page heading.
138
248
  * @property {string} [shape] - Human-readable shape, for documentation. Comes
139
249
  * paired with `read` from one of the coercion constants below.
140
250
  * @property {(raw: any, ctx: {fm: object, field: FieldSpec}) => any} [read] -
@@ -164,6 +274,78 @@ export function authoredFields(fields: readonly FieldSpec[]): FieldSpec[];
164
274
  * note, so it declares none.
165
275
  * @property {any|((fm: object) => any)} [value] - For a field with no `name`:
166
276
  * the constant, or a function deriving it from the frontmatter.
277
+ * @property {boolean} [omitWhenAbsent] - **The key is left out entirely when
278
+ * the note does not carry the field** (#329), rather than written from a
279
+ * declared default.
280
+ *
281
+ * Every other field answers absence with a value: an unauthored `weight` is
282
+ * `0`, an unauthored `seat` is `null`. That is right wherever the type has an
283
+ * opinion about the empty case. It is wrong wherever the **DataModel** is the
284
+ * one holding the answer — an affliction's `onsetDurationFormula` has no
285
+ * compile-time value, and writing `null` over it does not merely fail to
286
+ * help: it makes "the author said none" and "the author said nothing"
287
+ * indistinguishable to every reader downstream, and it overwrites an
288
+ * `initial` the system chose on purpose.
289
+ *
290
+ * It is the other conditional row of the same table
291
+ * {@link FieldSpec.runtimeOnly} completes, and the two differ only in what
292
+ * they do about an *authored* value:
293
+ *
294
+ * | declaration | authored | absent |
295
+ * | --- | --- | --- |
296
+ * | ordinary | emitted | default written |
297
+ * | `omitWhenAbsent` | emitted | key omitted |
298
+ * | `runtimeOnly` | refused | key omitted |
299
+ *
300
+ * **A field declaring it must declare no `default`**, and the two are
301
+ * contradictory rather than merely redundant — a default is a value for the
302
+ * absent case, which is the case this says has none. Nor may it be combined
303
+ * with `required` (which fails the build on absence, so nothing is ever
304
+ * omitted) or with `runtimeOnly` (which is never emitted at all). The shipped
305
+ * declarations are checked for all three in `tests/item-fields.test.ts`.
306
+ *
307
+ * Unlike `runtimeOnly` this is a flag rather than a reason, because there is
308
+ * only ever one reason and no message prints it: the DataModel's `initial`
309
+ * stands. What an author needs to know is *that* the field has no default,
310
+ * which the generated reference states in the field's own row.
311
+ * @property {string} [runtimeOnly] - **What the field holds once play has
312
+ * started** — declared on a field the *document* writes for itself, which no
313
+ * note may author (#330).
314
+ *
315
+ * A schema declares plenty of fields a compiled document has no business
316
+ * carrying: an affliction's `onsetDate` is the world time its onset fired
317
+ * at, crystallized when the phase runs. World time does not exist while
318
+ * content is compiled, so there is no authoring-time value — and `0` is
319
+ * itself a valid world time, which is why such a field is nullable rather
320
+ * than sentinelled and why a default cannot stand in for one.
321
+ *
322
+ * Nothing used to stop a note writing one. The three checks that might have
323
+ * each declined for its own correct reason —
324
+ * {@link module:engine/system-block.unknownBlockKeys} reads the block's top
325
+ * level and never descends into `system:`;
326
+ * {@link module:engine/system-block.mergeSystemData} passes through every
327
+ * authored path no declared field claims; and the schema check's fatal
328
+ * direction is *undeclared*, which a field the schema really does declare
329
+ * satisfies. What was missing was a rule saying "declared by the system,
330
+ * but never authorable", and this is it.
331
+ *
332
+ * Declaring it does two things, which are the two directions of one fact:
333
+ *
334
+ * | declaration | authored | absent |
335
+ * | --- | --- | --- |
336
+ * | ordinary | emitted | default written |
337
+ * | runtime-only | **refused** | key omitted |
338
+ *
339
+ * The refusal is {@link module:engine/runtime-only-fields.assertNoRuntimeOnlyFields}'s;
340
+ * the omission is {@link buildFromFields}'s. A runtime-only entry declares a
341
+ * `to` and **no `name`**, so it stays out of {@link authoredFields} and every
342
+ * author-facing surface built on it, while still giving `mergeSystemData` a
343
+ * claimed path and the refusal something to name.
344
+ *
345
+ * **The value is the reason**, as {@link FieldSpec.topLevelMeans}'s is: a
346
+ * boolean would record the decision and lose the case for it, and the reason
347
+ * is what the refusal's message and the generated reference both print. It
348
+ * completes the sentence "it holds …".
167
349
  * @property {string} describe - One line, for the author-facing reference.
168
350
  */
169
351
  /** Whatever the author wrote, unconverted. */
@@ -290,6 +472,13 @@ export type FieldSpec = {
290
472
  * describe the *document* rather than the note: `<system>.system.<to>` and
291
473
  * the legacy in-block `<system>.<name>`. Absent means the ordinary case —
292
474
  * the top level is read, as the third step.
475
+ *
476
+ * **It is read from the other side too**, because the statement is symmetric:
477
+ * if the two positions hold unrelated quantities then the *in-block* position
478
+ * is not the note-level field either, so a check about the note-level field
479
+ * reads past it. `engine/frontmatter-lint.mjs` resolves that through
480
+ * `collidingBlockKeys`. Read for the emitted field alone until #312, which is
481
+ * how an affiliation's office style came to answer for its page heading.
293
482
  */
294
483
  topLevelMeans?: string | undefined;
295
484
  /**
@@ -348,11 +537,90 @@ export type FieldSpec = {
348
537
  * the constant, or a function deriving it from the frontmatter.
349
538
  */
350
539
  value?: any | ((fm: object) => any);
540
+ /**
541
+ * - **The key is left out entirely when
542
+ * the note does not carry the field** (#329), rather than written from a
543
+ * declared default.
544
+ *
545
+ * Every other field answers absence with a value: an unauthored `weight` is
546
+ * `0`, an unauthored `seat` is `null`. That is right wherever the type has an
547
+ * opinion about the empty case. It is wrong wherever the **DataModel** is the
548
+ * one holding the answer — an affliction's `onsetDurationFormula` has no
549
+ * compile-time value, and writing `null` over it does not merely fail to
550
+ * help: it makes "the author said none" and "the author said nothing"
551
+ * indistinguishable to every reader downstream, and it overwrites an
552
+ * `initial` the system chose on purpose.
553
+ *
554
+ * It is the other conditional row of the same table
555
+ * {@link FieldSpec.runtimeOnly} completes, and the two differ only in what
556
+ * they do about an *authored* value:
557
+ *
558
+ * | declaration | authored | absent |
559
+ * | --- | --- | --- |
560
+ * | ordinary | emitted | default written |
561
+ * | `omitWhenAbsent` | emitted | key omitted |
562
+ * | `runtimeOnly` | refused | key omitted |
563
+ *
564
+ * **A field declaring it must declare no `default`**, and the two are
565
+ * contradictory rather than merely redundant — a default is a value for the
566
+ * absent case, which is the case this says has none. Nor may it be combined
567
+ * with `required` (which fails the build on absence, so nothing is ever
568
+ * omitted) or with `runtimeOnly` (which is never emitted at all). The shipped
569
+ * declarations are checked for all three in `tests/item-fields.test.ts`.
570
+ *
571
+ * Unlike `runtimeOnly` this is a flag rather than a reason, because there is
572
+ * only ever one reason and no message prints it: the DataModel's `initial`
573
+ * stands. What an author needs to know is *that* the field has no default,
574
+ * which the generated reference states in the field's own row.
575
+ */
576
+ omitWhenAbsent?: boolean | undefined;
577
+ /**
578
+ * - **What the field holds once play has
579
+ * started** — declared on a field the *document* writes for itself, which no
580
+ * note may author (#330).
581
+ *
582
+ * A schema declares plenty of fields a compiled document has no business
583
+ * carrying: an affliction's `onsetDate` is the world time its onset fired
584
+ * at, crystallized when the phase runs. World time does not exist while
585
+ * content is compiled, so there is no authoring-time value — and `0` is
586
+ * itself a valid world time, which is why such a field is nullable rather
587
+ * than sentinelled and why a default cannot stand in for one.
588
+ *
589
+ * Nothing used to stop a note writing one. The three checks that might have
590
+ * each declined for its own correct reason —
591
+ * {@link module :engine/system-block.unknownBlockKeys} reads the block's top
592
+ * level and never descends into `system:`;
593
+ * {@link module :engine/system-block.mergeSystemData} passes through every
594
+ * authored path no declared field claims; and the schema check's fatal
595
+ * direction is *undeclared*, which a field the schema really does declare
596
+ * satisfies. What was missing was a rule saying "declared by the system,
597
+ * but never authorable", and this is it.
598
+ *
599
+ * Declaring it does two things, which are the two directions of one fact:
600
+ *
601
+ * | declaration | authored | absent |
602
+ * | --- | --- | --- |
603
+ * | ordinary | emitted | default written |
604
+ * | runtime-only | **refused** | key omitted |
605
+ *
606
+ * The refusal is {@link module :engine/runtime-only-fields.assertNoRuntimeOnlyFields}'s;
607
+ * the omission is {@link buildFromFields}'s. A runtime-only entry declares a
608
+ * `to` and **no `name`**, so it stays out of {@link authoredFields} and every
609
+ * author-facing surface built on it, while still giving `mergeSystemData` a
610
+ * claimed path and the refusal something to name.
611
+ *
612
+ * **The value is the reason**, as {@link FieldSpec.topLevelMeans}'s is: a
613
+ * boolean would record the decision and lose the case for it, and the reason
614
+ * is what the refusal's message and the generated reference both print. It
615
+ * completes the sentence "it holds …".
616
+ */
617
+ runtimeOnly?: string | undefined;
351
618
  /**
352
619
  * - One line, for the author-facing reference.
353
620
  */
354
621
  describe: string;
355
622
  };
356
623
  import { legacyKeyOf } from "./system-block.mjs";
624
+ import { retiredTopLevelKey } from "./system-block.mjs";
357
625
  import { setPath } from "./system-block.mjs";
358
- export { legacyKeyOf, setPath };
626
+ export { legacyKeyOf, retiredTopLevelKey, setPath };
@@ -18,6 +18,26 @@ export function bareAddress(value: string | null): string | null;
18
18
  * @returns {string} `<pkg>-none-folder-<shortcode>`.
19
19
  */
20
20
  export function folderAddress(pkg: string, shortcode: string): string;
21
+ /**
22
+ * The Foundry `_id` a folder note's documents are filed under.
23
+ *
24
+ * **The one derivation, because two passes need the same answer.** The folder
25
+ * pass hashes it here on its way to emitting the `Folder` documents; the
26
+ * content index publishes it, through `noteDocId`, for a consumer who will
27
+ * never run this build. Deriving it twice is how the index came to publish a
28
+ * plausible-looking id that resolved to nothing — the general id rule hashes a
29
+ * note's address under the `document` namespace, and a folder's is hashed under
30
+ * {@link FOLDER_ID_NAMESPACE} (#310).
31
+ *
32
+ * An **authored `id` still wins**, and is applied by the caller: this is the
33
+ * derivation, not the resolution, so the pin rule stays stated once, where
34
+ * every other note type states it ({@link module:engine/note-ids.noteDocId}).
35
+ *
36
+ * @param {string} pkg - The content package.
37
+ * @param {string} shortcode - The folder's shortcode.
38
+ * @returns {string} The folder's 16-character Foundry id.
39
+ */
40
+ export function folderDocId(pkg: string, shortcode: string): string;
21
41
  /**
22
42
  * Collect every folder note in a content tree.
23
43
  *
@@ -29,6 +29,12 @@ export function anchorsOf(entryUuid: string, entryId: string, body: string, name
29
29
  * @param {string} name - The note's display name.
30
30
  * @param {string} address - The note's package-relative address.
31
31
  * @param {string} body - The note's markdown body.
32
+ * **Each entry carries the `id` of the document it addresses**, not only its
33
+ * UUID. The two are one fact — a UUID ends in the id — but only the entry knows
34
+ * which derivation produced it: an item's is its note's `fm.id`, and its
35
+ * documentation journal's is {@link itemDocEntryId} of that. Stating it here is
36
+ * what lets the content index publish an identity it did not re-derive (#310).
37
+ *
32
38
  * @param {object} ctx - Resolved identities: `{ contentPackage,
33
39
  * foundryPackageId, packRouter }`.
34
40
  * @returns {Array<object>} One or two entries, in {@link buildManifest}'s shape.
@@ -26,6 +26,12 @@ export function matchesKind(value: unknown, kind: string): boolean;
26
26
  * note against whatever its type declares and knows no type names of its
27
27
  * own. Its absence skips the `data:` and `subType` checks rather than
28
28
  * reporting every key as unknown.
29
+ * @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
30
+ * What art a note of one type reaches its document through — the passes' own
31
+ * declaration, asked through `engine/generate.mjs`'s `emittedArtFor`.
32
+ * Supplied by the caller like `schemas`, so this module states no list of
33
+ * iconless types of its own; absent it, an inert `img:` goes unreported
34
+ * rather than every note's being (#349).
29
35
  * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
30
36
  * The system blocks to check, and what each accepts. See
31
37
  * {@link DEFAULT_SYSTEM_BLOCKS}.
@@ -35,10 +41,14 @@ export function matchesKind(value: unknown, kind: string): boolean;
35
41
  * about those keys.
36
42
  * @returns {object[]} Findings, each with a locator where one is obtainable.
37
43
  */
38
- export function lintNote(note: object, { schemas, index, vocabulary, packs, systems }: {
44
+ export function lintNote(note: object, { schemas, index, vocabulary, packs, emittedArt, systems }: {
39
45
  schemas: Record<string, readonly object[]>;
40
46
  index?: object | undefined;
41
47
  vocabulary?: Record<string, object> | undefined;
48
+ emittedArt?: ((type: string) => {
49
+ document: string | null;
50
+ art: readonly string[];
51
+ } | null) | undefined;
42
52
  systems?: Readonly<Record<string, {
43
53
  known?: readonly string[];
44
54
  fieldVocabulary?: boolean;
@@ -58,10 +68,12 @@ export function lintNote(note: object, { schemas, index, vocabulary, packs, syst
58
68
  * The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
59
69
  * @param {readonly string[]} [opts.packs] - The declared pack names; see
60
70
  * {@link lintNote}.
71
+ * @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
72
+ * What art a type reaches its document through; see {@link lintNote}.
61
73
  * @returns {{findings: object[], notes: number}} The findings, and how many
62
74
  * notes were inspected.
63
75
  */
64
- export function lintFrontmatter(index: object, { schemas, vocabulary, packs, references, systems }: {
76
+ export function lintFrontmatter(index: object, { schemas, vocabulary, packs, emittedArt, references, systems }: {
65
77
  schemas: Record<string, readonly object[]>;
66
78
  vocabulary?: Record<string, object> | undefined;
67
79
  references?: boolean | undefined;
@@ -70,6 +82,10 @@ export function lintFrontmatter(index: object, { schemas, vocabulary, packs, ref
70
82
  fieldVocabulary?: boolean;
71
83
  }>> | undefined;
72
84
  packs?: readonly string[] | undefined;
85
+ emittedArt?: ((type: string) => {
86
+ document: string | null;
87
+ art: readonly string[];
88
+ } | null) | undefined;
73
89
  }): {
74
90
  findings: object[];
75
91
  notes: number;
@@ -132,13 +132,15 @@ export function parseValueDesc(raw: any): {
132
132
  maxValue: number;
133
133
  }[];
134
134
  /**
135
- * The compendium folder a note names, and how it named it.
135
+ * The compendium folder a note names.
136
136
  *
137
- * Two spellings, deliberately not merged into one value: `packFolder:` is a
138
- * folder note's **address** (`folder-poisonsandtoxins`) and `folder:` is a
139
- * Foundry **id** (`ONXsqZAIZr2qzxTb`). Which one a value is cannot be told from
140
- * the string both are alphanumeric so the field it was written in is what
141
- * says, and that answer is carried rather than re-derived (#251).
137
+ * **There is one spelling.** `packFolder:` is a folder note's **address**
138
+ * (`folder-poisonsandtoxins`), resolved through the address index the whole
139
+ * build shares. The `folder:` Foundry-id spelling this function once read
140
+ * beside it, and the per-pack `*-folders.yaml` that id was resolved against,
141
+ * are retired together (#260) a note declaring `folder:` is refused by
142
+ * {@link module:engine/folder-notes.assertNoDeclaredFolder} rather than
143
+ * reaching here, so there is no second source for a value to come from.
142
144
  *
143
145
  * **`packFolder` was a path for one release** (`Possessions/Misc_Gear/Cooking`)
144
146
  * and is an address now (#255). A path encoded the hierarchy *in the value*, so
@@ -149,13 +151,11 @@ export function parseValueDesc(raw: any): {
149
151
  * authors to migrate, which is the whole reason the change was cheap enough to
150
152
  * make.
151
153
  *
152
- * `packFolder` wins where both are present. Nothing about `folder` changes: a
153
- * note that names one is read, resolved and emitted exactly as before, until
154
- * #260 retires it.
155
- *
156
154
  * @param {object|null|undefined} fm - Parsed frontmatter.
157
155
  * @returns {{value: string|null, isAddress: boolean}} The authored value, and
158
- * whether it is a folder note's address.
156
+ * whether it is a folder note's address. `isAddress` is always `true` and is
157
+ * kept so a caller reads the same shape it always did; it distinguished the
158
+ * two spellings, and there is only one left to be.
159
159
  */
160
160
  export function folderField(fm: object | null | undefined): {
161
161
  value: string | null;
@@ -8,6 +8,33 @@
8
8
  * rather than defaulting past.
9
9
  */
10
10
  export function compilerFor(docType: string, system?: string | null): Function | undefined;
11
+ /**
12
+ * The art fields a note of one content type reaches its document through, and
13
+ * the document it reaches (#349).
14
+ *
15
+ * **Derived, never listed.** A note's type routes to a document type
16
+ * ({@link packForType}), a document type routes to the pass that compiles it
17
+ * ({@link compilerFor}), and the pass declares which art it emits
18
+ * ({@link BasePackCompiler.emitsArt}). So the answer is assembled from the same
19
+ * three statements the compile itself follows, and a pass that starts or stops
20
+ * emitting art changes this by changing its own declaration. A second table of
21
+ * "types with no image" would be a table free to drift from what is emitted,
22
+ * which is the defect this exists to report rather than to reproduce.
23
+ *
24
+ * **The union across systems**, because a note is compiled by whichever pack
25
+ * claims it: a tree feeding both SoHL and HM3 has two Actor passes, and a field
26
+ * either of them emits is live for the note. Only a field *no* pass emits is
27
+ * inert, and that is the finding this supports.
28
+ *
29
+ * @param {string} type - The note's content type.
30
+ * @returns {{document: string|null, art: readonly string[]}|null} What the type
31
+ * compiles into and the art it carries there, or `null` where no claim can be
32
+ * made — a retired type, which is reported as retired instead.
33
+ */
34
+ export function emittedArtFor(type: string): {
35
+ document: string | null;
36
+ art: readonly string[];
37
+ } | null;
11
38
  /**
12
39
  * The generated JSON of **every** configured Item pack — what the actors pass
13
40
  * reads its predefined items from.
@@ -169,19 +169,47 @@ export function systemTemplatePriority(fm: object, label: string): number | null
169
169
  * alphanumeric runs replaced by underscores.
170
170
  */
171
171
  export function makeFilename(name: any, id: any): string;
172
- /**
173
- * Standardize a name into a slug: lowercase, apostrophes removed,
174
- * non-alphanumerics collapsed to single hyphens.
175
- */
176
172
  /**
177
173
  * Translate a content-relative image path into its Foundry-relative form.
178
174
  *
179
175
  * Content frontmatter (`img` / `portrait`) authors a single path that has to
180
- * work for Foundry, the knowledgebase, and the website. For Foundry the bundled
181
- * asset roots `icons/...` and `images/...` are served from the package
182
- * directory, so they are rewritten to `<assetRoot>/<path>` — `systems/sohl/assets`
183
- * for this repository, `modules/<id>/assets` for a module (#1508). Any other
184
- * path (already package-rooted, an absolute URL) is returned unchanged.
176
+ * work for Foundry, the knowledgebase, and the website. **Its first segment
177
+ * says which package owns the file** (#331), and there are exactly three
178
+ * answers:
179
+ *
180
+ * | Authored path starts with | Owner | Emitted |
181
+ * | ------------------------- | --------------------- | -------------------- |
182
+ * | `systems/` | a separate **system** | unchanged |
183
+ * | `modules/` | a separate **module** | unchanged |
184
+ * | anything else | **this package** | `<assetRoot>/<path>` |
185
+ *
186
+ * So `icons/relic.svg` compiles to `systems/sohl/assets/icons/relic.svg` here
187
+ * and to `modules/sohl-thalorna/assets/icons/relic.svg` in a module — the asset
188
+ * root is derived from the configuration, and is the one place `systems/sohl`
189
+ * is ever spelled (#1508). An authored
190
+ * `systems/sohl/assets/icons/noun/shield.svg` is left exactly as written,
191
+ * whichever package is compiling it.
192
+ *
193
+ * **This is a rule about ownership, not an allowlist of directories.** It used
194
+ * to prefix `icons/…` and `images/…` and pass everything else through — the
195
+ * same answer for every path any tree authors today, and the wrong one for the
196
+ * next directory a package ships. `sohl-kethira-basic` keeps art under
197
+ * `assets/artwork/`, so an authored `artwork/deity.webp` would have shipped
198
+ * unprefixed: a 404 in Foundry, reported by nothing. That a package owns its
199
+ * own tree is the fact; the directory names inside it are that package's
200
+ * business (#331).
201
+ *
202
+ * **Off-install addresses pass through too**, which is the same rule rather
203
+ * than a fourth: a URL, a `data:` URI, or a `/`-rooted path names something no
204
+ * package owns. See {@link addressesAnotherPackage}.
205
+ *
206
+ * **`banner:` does not follow this rule, deliberately (#331).** It is not an
207
+ * asset path inside a Foundry install at all: it reaches no compiled document,
208
+ * and its only consumer is the Hugo theme, which prefixes a relative value with
209
+ * `images/` and joins it onto `params.cdnBaseURL`. The two fields look alike
210
+ * and address different places — `img:` a file Foundry serves, `banner:` a file
211
+ * the CDN serves — so they are documented apart rather than reconciled into one
212
+ * rule that would be true of neither.
185
213
  *
186
214
  * **Two empties, and they mean opposite things (#218).** `null` — or an absent
187
215
  * key, which reaches here as `undefined` — means _unset_: the note names no art