@heroiclands/package-build 18.2.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 +692 -0
- package/CONTENT.md +81 -10
- package/bin/content-build.mjs +7 -1
- package/ci/ci-docker.mjs +21 -0
- package/content-config.mjs +26 -24
- package/docs/content-format.md +408 -85
- package/engine/actor-compiler.mjs +197 -7
- package/engine/address-charset.mjs +23 -5
- package/engine/base-compiler.mjs +65 -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 +88 -1
- package/engine/foreign-catalog.mjs +4 -1
- package/engine/foundry-entries.mjs +16 -0
- package/engine/frontmatter-lint.mjs +215 -28
- package/engine/frontmatter.mjs +12 -12
- package/engine/generate.mjs +78 -46
- package/engine/helpers.mjs +87 -128
- package/engine/index.mjs +3 -0
- package/engine/item-compiler.mjs +44 -9
- package/engine/journals.mjs +27 -16
- 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 +19 -28
- 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 +70 -23
- 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/content-config.d.mts +0 -7
- 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 +59 -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 -38
- 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
|
@@ -80,10 +80,19 @@
|
|
|
80
80
|
* still compiles, correctly, and refusing it would fail a build over a document
|
|
81
81
|
* that is not wrong. Those retire in the three steps `package:` took (#56), and
|
|
82
82
|
* this module carries the **first**: both spellings are read, the current one
|
|
83
|
-
* wins, and the retired one is *reported* rather than refused.
|
|
84
|
-
* the refusal come later, once no tree writes it. See
|
|
83
|
+
* wins, and the retired one is *reported* rather than refused. See
|
|
85
84
|
* {@link RETIRED_FIELD_ALIASES}.
|
|
86
85
|
*
|
|
86
|
+
* **The third step is deletion, and it needs no code (#149).** `image`, a map's
|
|
87
|
+
* background art, is the first rename to have run all three: reported (#142),
|
|
88
|
+
* swept (SoHL#1801 and the position move that followed), then dropped from the
|
|
89
|
+
* table. Removing the entry is the whole of it — with no alias, the spelling is
|
|
90
|
+
* an ordinary unknown key in the `sohl:` block, which the frontmatter lint
|
|
91
|
+
* already refuses as an error alongside the required field it failed to supply.
|
|
92
|
+
* So nothing here *records* a finished retirement: the absence of an entry is
|
|
93
|
+
* the record, and a retirement that needed a standing refusal would mean the
|
|
94
|
+
* replacement never arrived. Do not keep a tombstone for one.
|
|
95
|
+
*
|
|
87
96
|
* **A retired *position* is the same case, and reads the same (#305).** A field
|
|
88
97
|
* whose shared source moved under `data:` is not renamed — `data.species` and
|
|
89
98
|
* `hm3.species` are one field written in two places — but the retirement has
|
|
@@ -93,6 +102,14 @@
|
|
|
93
102
|
* {@link module:engine/system-block.resolveFieldValue}'s answer; this module
|
|
94
103
|
* only says what an author is told about it.
|
|
95
104
|
*
|
|
105
|
+
* **A field has two retiring positions, not one (#332).** The in-block key is
|
|
106
|
+
* the obvious one; the other is the note's **top level**, because #128 did not
|
|
107
|
+
* invent the facts `data:` holds — it gathered them from exactly there. So
|
|
108
|
+
* `portrait:` beside `img:` is the pre-`data:` spelling of `data.portrait`,
|
|
109
|
+
* read for the same reason and reported by {@link retiredTopLevelMessage}. Both
|
|
110
|
+
* are needed, and separately: a note may have moved one and not the other, and
|
|
111
|
+
* a single finding covering both would name the wrong line half the time.
|
|
112
|
+
*
|
|
96
113
|
* @module
|
|
97
114
|
*/
|
|
98
115
|
|
|
@@ -100,6 +117,7 @@ import fs from "node:fs";
|
|
|
100
117
|
|
|
101
118
|
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
102
119
|
import { sohlField } from "./frontmatter.mjs";
|
|
120
|
+
import { retiredTopLevelKey } from "./system-block.mjs";
|
|
103
121
|
|
|
104
122
|
/**
|
|
105
123
|
* What a note declaring `draft:` is told, in one place.
|
|
@@ -417,8 +435,8 @@ export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel
|
|
|
417
435
|
* and what every reader asks for; the value is the spelling still honoured.
|
|
418
436
|
* The table is therefore scoped by the schema without saying so twice: an alias
|
|
419
437
|
* applies to a note only where that note's type declares the current field, so
|
|
420
|
-
* `
|
|
421
|
-
* key anywhere else.
|
|
438
|
+
* `relation` is retired on an affiliation — which declares `relations` — and
|
|
439
|
+
* remains an unknown key anywhere else.
|
|
422
440
|
*
|
|
423
441
|
* **`templatePriority` (#266).** The number that decides which of several
|
|
424
442
|
* competing templates the Create dialog offers was called `archetype`, and
|
|
@@ -433,18 +451,9 @@ export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel
|
|
|
433
451
|
* read past. Only `affiliation` declares the field, so the alias is reported
|
|
434
452
|
* there and the old spelling stays an ordinary unknown key everywhere else.
|
|
435
453
|
*
|
|
436
|
-
* **`img` (#142).** Every note type names its artwork `img`, at the note's top
|
|
437
|
-
* level, and resolves it the same way. A map alone named its background art
|
|
438
|
-
* `image` and read it out of the `sohl:` block — two spellings for one idea,
|
|
439
|
-
* with nothing to reconcile them, and a specification that had to hedge rather
|
|
440
|
-
* than state a rule. Art is not system-specific: a Scene is a core Foundry
|
|
441
|
-
* document and HM3 would want the identical one, so the field belongs beside
|
|
442
|
-
* every other note's `img`, not inside a system block.
|
|
443
|
-
*
|
|
444
454
|
* @type {Readonly<Record<string, string>>}
|
|
445
455
|
*/
|
|
446
456
|
export const RETIRED_FIELD_ALIASES = Object.freeze({
|
|
447
|
-
img: "image",
|
|
448
457
|
templatePriority: "archetype",
|
|
449
458
|
relations: "relation",
|
|
450
459
|
});
|
|
@@ -507,6 +516,38 @@ export function legacyKeyMessage(block, field, file) {
|
|
|
507
516
|
);
|
|
508
517
|
}
|
|
509
518
|
|
|
519
|
+
/**
|
|
520
|
+
* What a note writing a field at the **top-level key `data:` gathered it off**
|
|
521
|
+
* is told (#332).
|
|
522
|
+
*
|
|
523
|
+
* {@link legacyKeyMessage}'s counterpart for the other retiring position. #128
|
|
524
|
+
* did not invent the facts `data:` holds — it collected them out of the note's
|
|
525
|
+
* open top level — so `portrait:` beside `img:` is the *pre-`data:`* spelling
|
|
526
|
+
* of `data.portrait`, and both are read for the same reason both in-block
|
|
527
|
+
* spellings are: a package moves its corpus when it is ready, not on a flag day.
|
|
528
|
+
*
|
|
529
|
+
* It says nothing about which value is emitted, because that is not what an
|
|
530
|
+
* author needs from it. The value is the same either way; what the finding
|
|
531
|
+
* counts is one more note still on the old position.
|
|
532
|
+
*
|
|
533
|
+
* @param {{name?: string}} field - The declaration, which names the current
|
|
534
|
+
* position; the retiring one is derived from it.
|
|
535
|
+
* @param {string} [file] - The note's path, named in the message. Omit it where
|
|
536
|
+
* the caller emits through a diagnostic, whose locator already starts the
|
|
537
|
+
* line — repeating it prints the path twice.
|
|
538
|
+
* @returns {string} The message, unpunctuated at the end as a finding is.
|
|
539
|
+
*/
|
|
540
|
+
export function retiredTopLevelMessage(field, file) {
|
|
541
|
+
const retiring = retiredTopLevelKey(field);
|
|
542
|
+
return (
|
|
543
|
+
`top-level \`${retiring}:\` is the pre-\`data:\` position of the shared ` +
|
|
544
|
+
`\`${field.name}:\` — move it under \`data:\` instead` +
|
|
545
|
+
(file ? ` — ${file}` : "") +
|
|
546
|
+
`. Both are read and \`${field.name}\` wins, so the note compiles ` +
|
|
547
|
+
`identically either way; the top-level key is removed in a later release`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
510
551
|
/**
|
|
511
552
|
* Whether a note writes the retired spelling of a field, wherever it put it.
|
|
512
553
|
*
|
|
@@ -539,9 +580,9 @@ export function declaresRetiredAlias(fm, current) {
|
|
|
539
580
|
* first, then the note's top level — so a renamed field keeps working wherever
|
|
540
581
|
* it was already written while the canonical home is the top level.
|
|
541
582
|
*
|
|
542
|
-
* A blank value counts as absent: `
|
|
543
|
-
*
|
|
544
|
-
* author part-way through the rename means by it.
|
|
583
|
+
* A blank value counts as absent: `relations:` cleared in an editor means the
|
|
584
|
+
* note records no standings there, and falling through to the retired spelling
|
|
585
|
+
* is what an author part-way through the rename means by it.
|
|
545
586
|
*
|
|
546
587
|
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
547
588
|
* @param {string} current - The field's current name.
|
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Schema fields a note may **never** author, because the document writes them
|
|
16
|
+
* in play (#330).
|
|
17
|
+
*
|
|
18
|
+
* `retired-fields.mjs` refuses a field a note may no longer declare; this
|
|
19
|
+
* refuses one it never could. The two read alike deliberately — a note says one
|
|
20
|
+
* thing, the build does another, and nothing says so — but they are different
|
|
21
|
+
* facts, and only one of them is ever swept: a retired field goes away, while
|
|
22
|
+
* `onsetDate` is a permanent part of `affliction`'s schema that simply is not
|
|
23
|
+
* content.
|
|
24
|
+
*
|
|
25
|
+
* ## What was going wrong
|
|
26
|
+
*
|
|
27
|
+
* A DataModel declares plenty of fields a *compiled* document has no business
|
|
28
|
+
* carrying. SoHL's timed phases are the case that prompted this: each phase of
|
|
29
|
+
* an `affliction` or a `trauma` stores a `{…DurationFormula, …DurationBase,
|
|
30
|
+
* …Date}` triplet, and the third of them is instance state, crystallized when
|
|
31
|
+
* the phase fires. World time does not exist while content is compiled, and `0`
|
|
32
|
+
* is itself a valid world time — which is why the field is nullable rather than
|
|
33
|
+
* sentinelled, and why no default could stand in for the missing value.
|
|
34
|
+
*
|
|
35
|
+
* Nothing stopped a note writing one, and the build emitted it. Three checks
|
|
36
|
+
* each declined to catch it, every one of them for its own correct reason:
|
|
37
|
+
*
|
|
38
|
+
* - {@link module:engine/system-block.unknownBlockKeys} inspects the **top
|
|
39
|
+
* level** of a system block and deliberately never descends into `system:`,
|
|
40
|
+
* which is a passthrough for the system's own vocabulary.
|
|
41
|
+
* - {@link module:engine/system-block.mergeSystemData} writes every authored
|
|
42
|
+
* `system.*` path that no declared field claims — and no field claimed
|
|
43
|
+
* `contractDate`, so it passed through verbatim.
|
|
44
|
+
* - The schema check's fatal direction is *undeclared* — emitting a key the
|
|
45
|
+
* system does not define. `contractDate` **is** in the schema, so as far as
|
|
46
|
+
* that check can see the emitted key is legitimate.
|
|
47
|
+
*
|
|
48
|
+
* The gap was that no rule expressed "declared by the system, but never
|
|
49
|
+
* authorable". This module is that rule, and it is **declarative**: it knows no
|
|
50
|
+
* field names, only the {@link module:engine/field-spec.FieldSpec} property
|
|
51
|
+
* `runtimeOnly`, so it holds for any future runtime-only field of any system
|
|
52
|
+
* without being taught about it.
|
|
53
|
+
*
|
|
54
|
+
* ## Why a refusal rather than a drop
|
|
55
|
+
*
|
|
56
|
+
* Declaring the field claims its path, so the passthrough would leave it alone
|
|
57
|
+
* and the authored value would simply vanish — which is the silent-disagreement
|
|
58
|
+
* failure this package spends its time removing. And the consequence of getting
|
|
59
|
+
* it wrong is not a missing value but a shipped one: a compiled document
|
|
60
|
+
* carrying a world-time stamp is one world's play state, installed into every
|
|
61
|
+
* world that loads the pack.
|
|
62
|
+
*
|
|
63
|
+
* @module
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import { runtimeOnlyFields } from "./field-spec.mjs";
|
|
67
|
+
import { getFrontmatter } from "./frontmatter.mjs";
|
|
68
|
+
import { locateFrontmatterKey } from "./retired-fields.mjs";
|
|
69
|
+
import { SYSTEM_DATA_KEY, systemData } from "./system-block.mjs";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What a note authoring a runtime-only field is told, in one place.
|
|
73
|
+
*
|
|
74
|
+
* Shared by every caller that can meet one, because an author meets whichever
|
|
75
|
+
* runs first and they should read the same. It says what the field holds and
|
|
76
|
+
* that deleting the key is the whole fix — there is no value to correct, which
|
|
77
|
+
* is what separates this from an out-of-range one.
|
|
78
|
+
*
|
|
79
|
+
* The reason comes from the declaration rather than from here: this module
|
|
80
|
+
* knows no field names, and a message written per field would be a second
|
|
81
|
+
* statement of the fact the declaration already carries.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} key - The **whole key the note wrote**, from the region it
|
|
84
|
+
* sits in down to the field: `sohl.system.onsetDate` on the item's own note,
|
|
85
|
+
* `sohl.items[2].system.contractDate` on an actor's embedded entry. Composed
|
|
86
|
+
* by the caller, because only it knows where it found the value — and a
|
|
87
|
+
* message naming the leaf alone leaves an author a note to search.
|
|
88
|
+
* @param {import("./field-spec.mjs").FieldSpec} field - The declaration, which
|
|
89
|
+
* carries the reason.
|
|
90
|
+
* @param {string} [file] - The note's path, named in the message. Omit it where
|
|
91
|
+
* the caller emits through a diagnostic, whose locator already starts the
|
|
92
|
+
* line — repeating it prints the path twice.
|
|
93
|
+
* @returns {string} The message, unpunctuated at the end as a finding is.
|
|
94
|
+
*/
|
|
95
|
+
export function runtimeOnlyMessage(key, field, file) {
|
|
96
|
+
return (
|
|
97
|
+
`\`${key}:\` is runtime state, not ` +
|
|
98
|
+
`content — delete it` +
|
|
99
|
+
(file ? ` — ${file}` : "") +
|
|
100
|
+
`. It holds ${field.runtimeOnly}, so no value for it exists at compile ` +
|
|
101
|
+
`time, and a compiled document carrying one ships a fact about a world ` +
|
|
102
|
+
`the pack has never been loaded into. The key is left out of the ` +
|
|
103
|
+
`document entirely, so the DataModel's own initial value stands`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The runtime-only fields a note actually writes, in declaration order.
|
|
109
|
+
*
|
|
110
|
+
* **Presence is the whole test**, as it is for every retired field: an authored
|
|
111
|
+
* `onsetDate: null` is as much a claim about play state as a number is, and it
|
|
112
|
+
* is exactly the belief the message exists to correct. So the question is
|
|
113
|
+
* whether the path resolves to anything at all, never whether the value is a
|
|
114
|
+
* usable one.
|
|
115
|
+
*
|
|
116
|
+
* Only `<block>.system.<to>` is searched, because it is the only position a
|
|
117
|
+
* runtime-only field is reachable at. Such a declaration carries no `name`, so
|
|
118
|
+
* it has neither a legacy in-block key nor a shared top-level source — and a
|
|
119
|
+
* bare `<block>.onsetDate` is an unrecognized block key, which
|
|
120
|
+
* {@link module:engine/system-block.unknownBlockKeys} already reports as an
|
|
121
|
+
* error naming the note and the line.
|
|
122
|
+
*
|
|
123
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
124
|
+
* @param {readonly import("./field-spec.mjs").FieldSpec[]} [fields] - The
|
|
125
|
+
* type's field declaration.
|
|
126
|
+
* @param {object} options - Options.
|
|
127
|
+
* @param {string} options.block - The system block to look in.
|
|
128
|
+
* @returns {import("./field-spec.mjs").FieldSpec[]} The offending declarations.
|
|
129
|
+
*/
|
|
130
|
+
export function authoredRuntimeOnlyFields(fm, fields, { block } = {}) {
|
|
131
|
+
if (!fm || typeof fm !== "object" || !block) return [];
|
|
132
|
+
return runtimeOnlyIn(systemData(fm, block), fields);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The same question asked of a `system` block directly.
|
|
137
|
+
*
|
|
138
|
+
* A note's own block is reached through {@link authoredRuntimeOnlyFields}, but
|
|
139
|
+
* it is not the only place an author writes one: an actor note's `items:`
|
|
140
|
+
* entries carry a `system:` overlay that is deep-merged onto the template
|
|
141
|
+
* verbatim, with no field declaration in the path at all. That overlay is a
|
|
142
|
+
* `system` block by every meaning except where it sits, and a `contractDate`
|
|
143
|
+
* written there ships exactly as one written on the trauma's own note.
|
|
144
|
+
*
|
|
145
|
+
* @param {Record<string, unknown>|null|undefined} data - The authored `system`
|
|
146
|
+
* data.
|
|
147
|
+
* @param {readonly import("./field-spec.mjs").FieldSpec[]} [fields] - The
|
|
148
|
+
* type's field declaration.
|
|
149
|
+
* @returns {import("./field-spec.mjs").FieldSpec[]} The offending declarations.
|
|
150
|
+
*/
|
|
151
|
+
export function runtimeOnlyIn(data, fields) {
|
|
152
|
+
if (!data || typeof data !== "object") return [];
|
|
153
|
+
return runtimeOnlyFields(fields).filter(
|
|
154
|
+
(field) =>
|
|
155
|
+
typeof field.to === "string" &&
|
|
156
|
+
field.to !== "" &&
|
|
157
|
+
getFrontmatter(data, field.to, undefined) !== undefined,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Refuse a note that authors any of its type's runtime-only fields.
|
|
163
|
+
*
|
|
164
|
+
* Refused rather than reported: the note is not compiled, so nothing it would
|
|
165
|
+
* have emitted reaches a pack. What that costs is the caller's to decide — each
|
|
166
|
+
* of them counts the refused note and emits a located diagnostic, so a refusal
|
|
167
|
+
* is never a silent skip.
|
|
168
|
+
*
|
|
169
|
+
* The **first** offending field is thrown on. A note authoring a whole phase
|
|
170
|
+
* triplet would otherwise produce three findings that are one mistake, and the
|
|
171
|
+
* build stops on this note either way; the fix for the first is the fix for all
|
|
172
|
+
* of them.
|
|
173
|
+
*
|
|
174
|
+
* @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
|
|
175
|
+
* could not be parsed.
|
|
176
|
+
* @param {readonly import("./field-spec.mjs").FieldSpec[]} [fields] - The
|
|
177
|
+
* type's field declaration. A type that declares none — or declares no
|
|
178
|
+
* runtime-only field — passes.
|
|
179
|
+
* @param {object} options - Options.
|
|
180
|
+
* @param {string} options.block - The system block to look in.
|
|
181
|
+
* @param {string} [options.file] - The note's path, named in the message. Omit
|
|
182
|
+
* it where the caller emits through a diagnostic, which puts the locator at
|
|
183
|
+
* the start of the line already — repeating it prints the path twice.
|
|
184
|
+
* @param {string} [options.absPath] - The note's file on disk, read only on the
|
|
185
|
+
* failing path to locate the offending line and column. The position rides on
|
|
186
|
+
* the thrown error as `position`, for a caller that emits a diagnostic.
|
|
187
|
+
* @returns {void}
|
|
188
|
+
* @throws {Error} When the note authors one.
|
|
189
|
+
*/
|
|
190
|
+
export function assertNoRuntimeOnlyFields(fm, fields, { block, file, absPath } = {}) {
|
|
191
|
+
const [field] = authoredRuntimeOnlyFields(fm, fields, { block });
|
|
192
|
+
if (!field) return;
|
|
193
|
+
|
|
194
|
+
const key = `${block}.${SYSTEM_DATA_KEY}.${field.to}`;
|
|
195
|
+
const err = new Error(`${runtimeOnlyMessage(key, field, file)}.`);
|
|
196
|
+
// The **leaf** of the destination path, which is the key as the note writes
|
|
197
|
+
// it: `to` is dotted for a nested field, and a locator handed
|
|
198
|
+
// `charges.value` would find nothing. Deliberately not anchored at column 1
|
|
199
|
+
// — the key lives two levels in, under `<block>.system`.
|
|
200
|
+
const leaf = /** @type {string} */ (field.to).split(".").pop();
|
|
201
|
+
const position = locateFrontmatterKey(absPath, /** @type {string} */ (leaf));
|
|
202
|
+
if (position) err.position = position;
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
package/engine/scenes.mjs
CHANGED
|
@@ -73,13 +73,6 @@ import { packRouter } from "./pack-router.mjs";
|
|
|
73
73
|
import { foundryPackageId } from "./content-package.mjs";
|
|
74
74
|
import { itemDocEntryId } from "./item-docs.mjs";
|
|
75
75
|
import { behaviorDocId, buildScene, isMapType, regionDocId } from "./map-notes.mjs";
|
|
76
|
-
import {
|
|
77
|
-
RETIRED_FIELD_ALIASES,
|
|
78
|
-
declaresRetiredAlias,
|
|
79
|
-
locateFrontmatterKey,
|
|
80
|
-
readAliasedField,
|
|
81
|
-
retiredAliasMessage,
|
|
82
|
-
} from "./retired-fields.mjs";
|
|
83
76
|
|
|
84
77
|
/**
|
|
85
78
|
* Every SoHL action name this build knows about, for the `action:` warning on a
|
|
@@ -127,6 +120,17 @@ export class Scenes extends BasePackCompiler {
|
|
|
127
120
|
static id = "scenes";
|
|
128
121
|
static label = "map";
|
|
129
122
|
|
|
123
|
+
/**
|
|
124
|
+
* A map note's `img` is its background art, and it is **required**: the map
|
|
125
|
+
* compiler refuses a note without one. It lands on the scene's level rather
|
|
126
|
+
* than on a property spelled `img`, which makes no difference to the
|
|
127
|
+
* question this declaration answers — the authored path reaches the output
|
|
128
|
+
* (#349). The place Adventure this pass bundles carries it too.
|
|
129
|
+
*
|
|
130
|
+
* @type {readonly string[]}
|
|
131
|
+
*/
|
|
132
|
+
static emitsArt = Object.freeze(["img"]);
|
|
133
|
+
|
|
130
134
|
/** @type {string} */
|
|
131
135
|
adventureDir;
|
|
132
136
|
|
|
@@ -408,19 +412,8 @@ export class Scenes extends BasePackCompiler {
|
|
|
408
412
|
// shared `docEntryTypes` arrangement (#1514) — so neither
|
|
409
413
|
// pass has to read the other's output.
|
|
410
414
|
const entryId = hasBody ? itemDocEntryId(fm.id) : undefined;
|
|
411
|
-
const { value: authoredFolder
|
|
412
|
-
const folder = this.folderResolver(authoredFolder, { isAddress:
|
|
413
|
-
// The retired spelling of the background art, reported where an author
|
|
414
|
-
// meets it soonest — every consumer runs the compile, and not every
|
|
415
|
-
// one runs the lint (#142). Located by reading the note back, which is
|
|
416
|
-
// what the other retired-field reports do: this is the one path that
|
|
417
|
-
// needs the position, so it is paid for only here.
|
|
418
|
-
if (declaresRetiredAlias(fm, "img")) {
|
|
419
|
-
this.noteWarn(
|
|
420
|
-
retiredAliasMessage(RETIRED_FIELD_ALIASES.img, "img"),
|
|
421
|
-
locateFrontmatterKey(this.currentNote?.absPath, RETIRED_FIELD_ALIASES.img),
|
|
422
|
-
);
|
|
423
|
-
}
|
|
415
|
+
const { value: authoredFolder } = folderField(fm);
|
|
416
|
+
const folder = this.folderResolver(authoredFolder, { isAddress: true });
|
|
424
417
|
const warnings = [];
|
|
425
418
|
const scene = buildScene(fm, {
|
|
426
419
|
packageId: foundryPackageId(),
|
|
@@ -457,13 +450,11 @@ export class Scenes extends BasePackCompiler {
|
|
|
457
450
|
name,
|
|
458
451
|
markdown,
|
|
459
452
|
leadName: name,
|
|
460
|
-
// As in the journals pass: an
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
this.folderResolver(authoredFolder, { isAddress: true })
|
|
466
|
-
: authoredFolder,
|
|
453
|
+
// As in the journals pass: an address resolves in the
|
|
454
|
+
// pack that emits it, which is what makes the folder
|
|
455
|
+
// materialise there too (#257). The id spelling that used
|
|
456
|
+
// to cross packs verbatim is retired (#260).
|
|
457
|
+
folder: this.folderResolver(authoredFolder, { isAddress: true }),
|
|
467
458
|
flags: fm.flags,
|
|
468
459
|
})
|
|
469
460
|
: null;
|
|
@@ -473,7 +464,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
473
464
|
this.places.set(placeKey, {
|
|
474
465
|
key: placeKey,
|
|
475
466
|
name: sohlField(fm, "placeName", null) || name,
|
|
476
|
-
img:
|
|
467
|
+
img: sohlField(fm, "img", null),
|
|
477
468
|
pinned: false,
|
|
478
469
|
scenes: [],
|
|
479
470
|
journal: [],
|
package/engine/schema-check.mjs
CHANGED
|
@@ -68,6 +68,9 @@ import path from "node:path";
|
|
|
68
68
|
import { cachedSchemaPath, SCHEMA_ARTIFACT_FILE } from "./foreign-catalog.mjs";
|
|
69
69
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
70
70
|
import { systemData, systemDataPaths, undeclaredPaths } from "./system-block.mjs";
|
|
71
|
+
// A field the document writes for itself in play: declared by the schema,
|
|
72
|
+
// emitted by no builder, and authored by no note (#330).
|
|
73
|
+
import { runtimeOnlyFields } from "./field-spec.mjs";
|
|
71
74
|
|
|
72
75
|
/**
|
|
73
76
|
* The artifact version this module reads.
|
|
@@ -133,12 +136,21 @@ export function declaredFields(artifact, documentType, subtype) {
|
|
|
133
136
|
* the path beneath it separately, so a comparison that knew only the leaf would
|
|
134
137
|
* report the container as unemitted and the leaf as undeclared.
|
|
135
138
|
*
|
|
136
|
-
*
|
|
139
|
+
* **A runtime-only field is not in it** (#330). It declares a `to` in order to
|
|
140
|
+
* *claim* the path — so the verbatim passthrough leaves it alone and the
|
|
141
|
+
* refusal has something to name — and `buildFromFields` deliberately skips it,
|
|
142
|
+
* because the document writes that field in play. Counting it here would make
|
|
143
|
+
* the check assert the builder writes a key it never writes; the *unemitted*
|
|
144
|
+
* direction handles it instead, in {@link compareFields}.
|
|
145
|
+
*
|
|
146
|
+
* @param {readonly {to: string, runtimeOnly?: string}[]} fields - A type's
|
|
147
|
+
* field declaration.
|
|
137
148
|
* @returns {Set<string>} The paths, parents included.
|
|
138
149
|
*/
|
|
139
150
|
export function emittedFields(fields) {
|
|
140
151
|
const out = new Set();
|
|
141
152
|
for (const field of fields ?? []) {
|
|
153
|
+
if (field?.runtimeOnly) continue;
|
|
142
154
|
if (typeof field?.to !== "string" || !field.to) continue;
|
|
143
155
|
const parts = field.to.split(".");
|
|
144
156
|
for (let i = 1; i <= parts.length; i++) {
|
|
@@ -221,6 +233,15 @@ export function compareFields({
|
|
|
221
233
|
}
|
|
222
234
|
|
|
223
235
|
const emitted = emittedFields(fields);
|
|
236
|
+
// Paths the declaration says the *document* writes in play (#330). They
|
|
237
|
+
// are neither emitted nor a defect, so they answer the unemitted
|
|
238
|
+
// question below rather than appearing in it: "every compiled document
|
|
239
|
+
// will carry the field's initial value" is exactly what a runtime-only
|
|
240
|
+
// field is for, and reporting it would leave a permanent warning that
|
|
241
|
+
// the correct declaration cannot clear.
|
|
242
|
+
const runtimeOnly = new Set(
|
|
243
|
+
runtimeOnlyFields(/** @type {never} */ (fields)).map((field) => field.to),
|
|
244
|
+
);
|
|
224
245
|
for (const path of emitted) {
|
|
225
246
|
if (declared.all.has(path)) continue;
|
|
226
247
|
undeclared.push({
|
|
@@ -238,6 +259,7 @@ export function compareFields({
|
|
|
238
259
|
// on a type that populates them correctly — two findings, both
|
|
239
260
|
// false, on the first real schema this was run against.
|
|
240
261
|
if (coveredByAncestor(path, emitted)) continue;
|
|
262
|
+
if (runtimeOnly.has(path)) continue;
|
|
241
263
|
unemitted.push({
|
|
242
264
|
type,
|
|
243
265
|
subtype,
|
package/engine/site-index.mjs
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
import path from "node:path";
|
|
53
53
|
|
|
54
54
|
import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
|
|
55
|
+
import { NO_SYSTEM } from "./systems.mjs";
|
|
55
56
|
import { systemOf } from "./document-subtypes.mjs";
|
|
56
57
|
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
|
|
57
58
|
import { hasDocEntry } from "./item-docs.mjs";
|
|
@@ -266,6 +267,17 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
|
|
|
266
267
|
if (hasDocEntry(type)) {
|
|
267
268
|
contentTypes.add(`doc${type}`);
|
|
268
269
|
index.set(`doc${type}/${shortcode}`.toLowerCase(), value);
|
|
270
|
+
// The canonical documentation address too, so the page answers
|
|
271
|
+
// to the address a bare prose link expands to (#336): body
|
|
272
|
+
// prose is under no system block, so it defaults to `none`, and
|
|
273
|
+
// a system-bearing type's `none` address is its `doc<type>`
|
|
274
|
+
// one. In Foundry that names a second document; here it names
|
|
275
|
+
// this same page, which is what makes one authored link correct
|
|
276
|
+
// in both builds.
|
|
277
|
+
index.set(
|
|
278
|
+
canonicalKey(e.pkg ?? ownPackage, NO_SYSTEM, `doc${type}`, shortcode),
|
|
279
|
+
value,
|
|
280
|
+
);
|
|
269
281
|
}
|
|
270
282
|
}
|
|
271
283
|
}
|
|
@@ -316,6 +328,11 @@ export function wikiContext(built, { src, file, type = null, errors, foreignInde
|
|
|
316
328
|
sections: built.sections,
|
|
317
329
|
contentTypes: built.contentTypes,
|
|
318
330
|
packages: built.packages,
|
|
331
|
+
// The package a link written on this page defaults to when it names
|
|
332
|
+
// none (#336). Taken from the resolved configuration, the same source
|
|
333
|
+
// the index's own addresses are built from, so a bare link cannot
|
|
334
|
+
// resolve against a package the index never keyed.
|
|
335
|
+
contentPackage: contentPackage(),
|
|
319
336
|
type,
|
|
320
337
|
errors,
|
|
321
338
|
src,
|
|
@@ -64,6 +64,36 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
|
|
|
64
64
|
HM3_DOCUMENT_SUBTYPES,
|
|
65
65
|
]);
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Every note type any shipped map compiles into an **Actor**.
|
|
69
|
+
*
|
|
70
|
+
* Derived from the maps rather than written out, so a system that adds an actor
|
|
71
|
+
* type is covered without a second list to keep in step — the same reason
|
|
72
|
+
* {@link KNOWN_DOCUMENT_SUBTYPE_MAPS} exists rather than a hand-kept table.
|
|
73
|
+
*
|
|
74
|
+
* It exists because an actor note publishes documentation like every other
|
|
75
|
+
* system-bearing note (#337). `docEntryTypes` was `itemTypes` plus `macro` and
|
|
76
|
+
* the map types, which left a being as the one system-bearing note with no
|
|
77
|
+
* `none` address — nothing a prose link could land on, since its only address
|
|
78
|
+
* named the Actor. Composing that set needs to know which types are actors, and
|
|
79
|
+
* this is where the maps that know already live.
|
|
80
|
+
*
|
|
81
|
+
* **Not an item type.** This widens what carries *documentation*; it must never
|
|
82
|
+
* widen what the items pass compiles, or a being note would be compiled into an
|
|
83
|
+
* Item beside its Actor.
|
|
84
|
+
*
|
|
85
|
+
* @type {ReadonlySet<string>}
|
|
86
|
+
*/
|
|
87
|
+
export const ACTOR_TYPES = Object.freeze(
|
|
88
|
+
new Set(
|
|
89
|
+
KNOWN_DOCUMENT_SUBTYPE_MAPS.flatMap((map) =>
|
|
90
|
+
Object.entries(map.types)
|
|
91
|
+
.filter(([, row]) => row?.document === "Actor")
|
|
92
|
+
.map(([noteType]) => noteType),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
|
|
67
97
|
/**
|
|
68
98
|
* The map one system ships, by its id.
|
|
69
99
|
*
|
package/engine/system-block.mjs
CHANGED
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
* kept until #126 moves it;
|
|
51
51
|
* 3. the shared top-level property the field **declares** as its source, which
|
|
52
52
|
* may be a dotted path (`data.portrait`) rather than a sibling key;
|
|
53
|
+
* 3b. for a `data.` source, the bare top-level key that container gathered it
|
|
54
|
+
* off — the retiring *shared* position, derived rather than declared;
|
|
53
55
|
* 4. the field's own default.
|
|
54
56
|
*
|
|
55
57
|
* ## Steps 2 and 3 are two declarations, because they are two positions
|
|
@@ -75,6 +77,29 @@
|
|
|
75
77
|
* that declares one is mid-sweep by construction, which is what
|
|
76
78
|
* {@link module:engine/field-spec.readsLegacyKey} reports on.
|
|
77
79
|
*
|
|
80
|
+
* ## Step 3 has a retiring position too, and it is derived (#332)
|
|
81
|
+
*
|
|
82
|
+
* `legacyKey` retires the *in-block* position, and for a while that looked like
|
|
83
|
+
* the whole of what `data:` left behind. It is not. The facts `data:` holds
|
|
84
|
+
* were not invented by it — #128 **gathered** them out of the note's open top
|
|
85
|
+
* level, where `portrait:` sat beside `img:` and `shortcode:` — so a field that
|
|
86
|
+
* declares `data.portrait` has two shared spellings to read, not one, and
|
|
87
|
+
* reading only the current one is the same silent miss `legacyKey` exists to
|
|
88
|
+
* prevent. It is worse here, because the caller's `?? default` cannot tell a
|
|
89
|
+
* value that is absent from one that is merely unreachable: 646 `sohl-thalorna`
|
|
90
|
+
* beings shipped the generic person icon over an authored path, and no tree
|
|
91
|
+
* that still writes the top-level spelling — `sohl`'s own bestiary does —
|
|
92
|
+
* looked any different from one that names no art at all.
|
|
93
|
+
*
|
|
94
|
+
* So step 3b reads it, and unlike `legacyKey` it is **derived**: the retiring
|
|
95
|
+
* spelling of `data.<key>` is `<key>`, mechanically, because that is precisely
|
|
96
|
+
* what the move did. A second declaration would be a second place for the same
|
|
97
|
+
* fact to be stated, and the retirement it describes is one rule rather than a
|
|
98
|
+
* per-field decision. {@link retiredTopLevelKey} is that derivation, and it
|
|
99
|
+
* answers for `data.` sources alone — `protection.blunt` is a path into a
|
|
100
|
+
* container notes have always written at the top level, never a `blunt:` that
|
|
101
|
+
* moved.
|
|
102
|
+
*
|
|
78
103
|
* ## A name that collides across the two vocabularies skips step 3
|
|
79
104
|
*
|
|
80
105
|
* A field's `name` is both its identity and the shared property it draws from,
|
|
@@ -90,8 +115,10 @@
|
|
|
90
115
|
* `"null"` in fifteen documents (#218).
|
|
91
116
|
*
|
|
92
117
|
* So a field may declare `topLevelMeans`: what the top-level key of that name
|
|
93
|
-
* means *instead*. Declaring it removes
|
|
94
|
-
*
|
|
118
|
+
* means *instead*. Declaring it removes the whole shared level — step 3 and the
|
|
119
|
+
* retiring 3b alike, since both read the note's top level and the objection is
|
|
120
|
+
* to that level, not to a spelling — leaving the two positions that describe
|
|
121
|
+
* the document rather than the note. It is deliberately
|
|
95
122
|
* a per-field opt-out rather than a change to the order — step 3 is right
|
|
96
123
|
* wherever the two levels state the same quantity, which is nearly everywhere —
|
|
97
124
|
* and its value is the reason rather than a bare flag, so the collision is
|
|
@@ -356,6 +383,43 @@ export function legacyKeyOf(field) {
|
|
|
356
383
|
return field?.legacyKey ?? field?.name;
|
|
357
384
|
}
|
|
358
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The `data:` container's prefix, as a shared source spells it.
|
|
388
|
+
*
|
|
389
|
+
* @type {string}
|
|
390
|
+
*/
|
|
391
|
+
const DATA_PREFIX = "data.";
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The bare top-level key a `data:`-sourced field is being swept off — step 3b.
|
|
395
|
+
*
|
|
396
|
+
* `data:` (#128) did not invent the facts it holds; it *gathered* them, out of
|
|
397
|
+
* the note's open top level where each was a sibling of `img` and `shortcode`.
|
|
398
|
+
* So the retiring spelling of `data.portrait` is not a second declaration
|
|
399
|
+
* anyone has to write — it is `portrait`, mechanically, and the same holds for
|
|
400
|
+
* every other key that move relocated. Deriving it is what keeps the two
|
|
401
|
+
* spellings of one field from disagreeing the way two declarations would.
|
|
402
|
+
*
|
|
403
|
+
* **Only a `data.` source has one.** `protection.blunt` and `impact.die` are
|
|
404
|
+
* paths into containers a note has always written at the top level; they were
|
|
405
|
+
* never `blunt:` or `die:`, and reading those would invent a position rather
|
|
406
|
+
* than remember one.
|
|
407
|
+
*
|
|
408
|
+
* A field declaring {@link module:engine/field-spec.FieldSpec `topLevelMeans`}
|
|
409
|
+
* has no shared position at all, retiring or otherwise — the resolver checks
|
|
410
|
+
* that before asking.
|
|
411
|
+
*
|
|
412
|
+
* @param {{name?: string}} field - The declaration.
|
|
413
|
+
* @returns {string|undefined} The retiring top-level path, or `undefined` for a
|
|
414
|
+
* field whose shared source never lived there.
|
|
415
|
+
*/
|
|
416
|
+
export function retiredTopLevelKey(field) {
|
|
417
|
+
const name = field?.name;
|
|
418
|
+
if (typeof name !== "string" || !name.startsWith(DATA_PREFIX)) return undefined;
|
|
419
|
+
const rest = name.slice(DATA_PREFIX.length);
|
|
420
|
+
return rest === "" ? undefined : rest;
|
|
421
|
+
}
|
|
422
|
+
|
|
359
423
|
/**
|
|
360
424
|
* Where a declared field's value came from.
|
|
361
425
|
*
|
|
@@ -363,7 +427,7 @@ export function legacyKeyOf(field) {
|
|
|
363
427
|
* can distinguish a value an author wrote from one a default supplied, which
|
|
364
428
|
* the value alone never says.
|
|
365
429
|
*
|
|
366
|
-
* @typedef {"system"|"block"|"shared"|"default"|"value"} FieldSource
|
|
430
|
+
* @typedef {"system"|"block"|"shared"|"topLevel"|"default"|"value"} FieldSource
|
|
367
431
|
*/
|
|
368
432
|
|
|
369
433
|
/**
|
|
@@ -422,6 +486,20 @@ export function resolveFieldValue(field, fm, { block = "sohl" } = {}) {
|
|
|
422
486
|
if (field.topLevelMeans === undefined) {
|
|
423
487
|
const shared = getFrontmatter(fm, field.name, undefined);
|
|
424
488
|
if (shared !== undefined) return { value: shared, from: "shared" };
|
|
489
|
+
|
|
490
|
+
// 3b. The bare top-level key the `data:` source was gathered off — the
|
|
491
|
+
// retiring *shared* position, exactly as `legacyKey` is the
|
|
492
|
+
// retiring *in-block* one (#332). Without it a field declaring
|
|
493
|
+
// `data.portrait` cannot see the `portrait:` every tree still
|
|
494
|
+
// writes, and the miss arrives at the caller's `?? default` as an
|
|
495
|
+
// ordinary absence: 646 `sohl-thalorna` beings compiled the generic
|
|
496
|
+
// person icon over an authored path, deterministically and with
|
|
497
|
+
// nothing said.
|
|
498
|
+
const retiring = retiredTopLevelKey(field);
|
|
499
|
+
if (retiring !== undefined) {
|
|
500
|
+
const legacy = getFrontmatter(fm, retiring, undefined);
|
|
501
|
+
if (legacy !== undefined) return { value: legacy, from: "topLevel" };
|
|
502
|
+
}
|
|
425
503
|
}
|
|
426
504
|
|
|
427
505
|
// 4. The field's own default.
|