@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.
- package/CHANGELOG.md +624 -0
- package/CONTENT.md +79 -8
- package/bin/content-build.mjs +7 -1
- package/content-config.mjs +10 -1
- package/docs/content-format.md +394 -72
- 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-format.mjs +102 -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 +4 -1
- package/engine/foundry-entries.mjs +14 -0
- package/engine/frontmatter-lint.mjs +186 -27
- package/engine/frontmatter.mjs +11 -11
- package/engine/generate.mjs +67 -10
- package/engine/helpers.mjs +86 -9
- package/engine/index.mjs +3 -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-ids.mjs +25 -1
- package/engine/note-vocabulary.mjs +76 -9
- 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/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/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-format.d.mts +9 -0
- package/types/engine/field-spec.d.mts +271 -3
- package/types/engine/folder-notes.d.mts +20 -0
- package/types/engine/foundry-entries.d.mts +6 -0
- package/types/engine/frontmatter-lint.d.mts +18 -2
- package/types/engine/frontmatter.d.mts +11 -11
- package/types/engine/generate.d.mts +27 -0
- package/types/engine/helpers.d.mts +37 -9
- package/types/engine/index.d.mts +1 -0
- package/types/engine/map-notes.d.mts +2 -2
- package/types/engine/note-ids.d.mts +14 -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
package/engine/content-index.mjs
CHANGED
|
@@ -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
|
|
494
|
-
* — plus `documents`, naming the record it is the documentation
|
|
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
|
|
606
|
-
//
|
|
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") ||
|
package/engine/content-links.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
199
|
-
//
|
|
200
|
-
//
|
|
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
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
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
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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
|
-
|
|
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
|
},
|