@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.
- package/CHANGELOG.md +1100 -0
- package/CONTENT.md +264 -33
- package/README.md +43 -4
- package/bin/content-build.mjs +94 -3
- package/config.mjs +9 -1
- package/content-config.mjs +99 -19
- package/docs/content-format.md +394 -72
- package/e2e.mjs +297 -3
- package/engine/actor-compiler.mjs +197 -7
- package/engine/address-charset.mjs +23 -5
- package/engine/base-compiler.mjs +63 -2
- package/engine/bundles.mjs +9 -0
- package/engine/content-address.mjs +92 -1
- package/engine/content-charset.mjs +434 -0
- package/engine/content-format.mjs +102 -0
- package/engine/content-icons.mjs +388 -0
- package/engine/content-index.mjs +11 -8
- package/engine/content-links.mjs +37 -21
- package/engine/field-reference.mjs +57 -5
- package/engine/field-spec.mjs +214 -7
- package/engine/folder-notes.mjs +24 -1
- package/engine/foreign-catalog.mjs +112 -7
- package/engine/foundry-entries.mjs +14 -0
- package/engine/frontmatter-lint.mjs +377 -56
- package/engine/frontmatter.mjs +11 -11
- package/engine/generate.mjs +72 -12
- package/engine/helpers.mjs +96 -10
- package/engine/index.mjs +9 -0
- package/engine/item-compiler.mjs +37 -0
- package/engine/journals.mjs +21 -4
- package/engine/macros.mjs +8 -0
- package/engine/map-notes.mjs +7 -7
- package/engine/note-claims.mjs +208 -5
- package/engine/note-ids.mjs +25 -1
- package/engine/note-vocabulary.mjs +76 -9
- package/engine/pack-config.mjs +102 -12
- package/engine/pack-router.mjs +0 -0
- package/engine/prose-config.mjs +42 -0
- package/engine/prose-lint.mjs +126 -0
- package/engine/retired-fields.mjs +57 -16
- package/engine/runtime-only-fields.mjs +204 -0
- package/engine/scenes.mjs +12 -19
- package/engine/schema-check.mjs +23 -1
- package/engine/schema-extract.mjs +13 -0
- package/engine/site-index.mjs +17 -0
- package/engine/subtype-registry.mjs +30 -0
- package/engine/system-block.mjs +81 -3
- package/engine/web-wikilinks.mjs +33 -27
- package/engine/wikilink-syntax.mjs +7 -0
- package/engine/wikilinks.mjs +74 -16
- package/hm3/actors.mjs +63 -13
- package/package.json +2 -2
- package/sohl/actors.mjs +106 -7
- package/sohl/item-fields.mjs +203 -0
- package/sohl/note-schemas.mjs +6 -3
- package/types/config.d.mts +7 -0
- package/types/e2e.d.mts +130 -3
- package/types/engine/actor-compiler.d.mts +83 -3
- package/types/engine/address-charset.d.mts +22 -4
- package/types/engine/base-compiler.d.mts +54 -3
- package/types/engine/content-address.d.mts +64 -0
- package/types/engine/content-charset.d.mts +127 -0
- package/types/engine/content-format.d.mts +9 -0
- package/types/engine/content-icons.d.mts +151 -0
- package/types/engine/field-spec.d.mts +271 -3
- package/types/engine/folder-notes.d.mts +20 -0
- package/types/engine/foreign-catalog.d.mts +38 -2
- package/types/engine/foundry-entries.d.mts +6 -0
- package/types/engine/frontmatter-lint.d.mts +164 -30
- package/types/engine/frontmatter.d.mts +11 -11
- package/types/engine/generate.d.mts +27 -0
- package/types/engine/helpers.d.mts +45 -9
- package/types/engine/index.d.mts +3 -0
- package/types/engine/map-notes.d.mts +2 -2
- package/types/engine/note-claims.d.mts +67 -0
- package/types/engine/note-ids.d.mts +14 -0
- package/types/engine/pack-config.d.mts +35 -0
- package/types/engine/prose-config.d.mts +41 -0
- package/types/engine/prose-lint.d.mts +36 -0
- package/types/engine/retired-fields.d.mts +29 -13
- package/types/engine/runtime-only-fields.d.mts +102 -0
- package/types/engine/schema-check.d.mts +10 -1
- package/types/engine/subtype-registry.d.mts +21 -0
- package/types/engine/system-block.d.mts +28 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
225
|
-
`
|
|
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
|
);
|
package/engine/field-spec.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/engine/folder-notes.mjs
CHANGED
|
@@ -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 ||
|
|
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) =>
|
|
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(
|
|
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
|
-
* @
|
|
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
|
|
668
|
-
|
|
669
|
-
|
|
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
|
},
|