@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
|
@@ -121,24 +121,152 @@ export const UNIVERSAL_KEYS = Object.freeze(
|
|
|
121
121
|
* The system blocks a build checks, and what each accepts beyond the shared
|
|
122
122
|
* vocabulary.
|
|
123
123
|
*
|
|
124
|
-
* One entry, because one system is what every existing tree declares — and
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
124
|
+
* One entry, because one system is what every existing tree declares — and it
|
|
125
|
+
* is a *fallback*, not the rule. {@link systemBlocksFor} derives the map from
|
|
126
|
+
* the configuration, which is what makes the block a package actually ships for
|
|
127
|
+
* the block that gets checked; this is what a caller holding no configuration
|
|
128
|
+
* gets, which in practice is a unit test.
|
|
129
|
+
*
|
|
130
|
+
* A block nothing declares is not checked, because nothing can say what it may
|
|
131
|
+
* carry, and inventing a rule for it would report a correct tree red.
|
|
132
|
+
*
|
|
133
|
+
* Three ways a block may state its vocabulary, and a spec declares at most one:
|
|
134
|
+
*
|
|
135
|
+
* - `known` — an explicit list of keys, for a caller stating them outright.
|
|
136
|
+
* - `fieldVocabulary` — the note type's own declared field names, as the
|
|
137
|
+
* caller's `schemas` state them, are keys of this block. That holds for the
|
|
138
|
+
* **one** system a single-registry tree ships for, where `schemas` *is* that
|
|
139
|
+
* system's vocabulary, and in general holds for no other.
|
|
140
|
+
* - `fields` — type → that system's own declared fields, from the registry the
|
|
141
|
+
* system declares. What a second system's block is checked against, because a
|
|
142
|
+
* second system's notes write a second system's fields and the note-type
|
|
143
|
+
* schemas describe somebody else's.
|
|
144
|
+
*
|
|
145
|
+
* @type {Readonly<Record<string, SystemBlockSpec>>}
|
|
137
146
|
*/
|
|
138
147
|
export const DEFAULT_SYSTEM_BLOCKS = Object.freeze({
|
|
139
148
|
sohl: Object.freeze({ fieldVocabulary: true }),
|
|
140
149
|
});
|
|
141
150
|
|
|
151
|
+
/**
|
|
152
|
+
* What one system block accepts beyond the keys every block carries.
|
|
153
|
+
*
|
|
154
|
+
* @typedef {object} SystemBlockSpec
|
|
155
|
+
* @property {readonly string[]} [known] - Keys stated outright.
|
|
156
|
+
* @property {boolean} [fieldVocabulary] - Whether the note type's declared field
|
|
157
|
+
* names, as the caller's `schemas` state them, are keys of this block.
|
|
158
|
+
* @property {Readonly<Record<string, readonly object[]>>} [fields] - Type → this
|
|
159
|
+
* system's own declared fields. A type it does not name is a type this system
|
|
160
|
+
* says nothing about, and its block is left unchecked on such a note rather
|
|
161
|
+
* than reported wholesale.
|
|
162
|
+
*/
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every system a configuration says its tree carries (#58).
|
|
166
|
+
*
|
|
167
|
+
* **Which systems a package ships for is already declared**, in three places
|
|
168
|
+
* that answer different questions, so this reads all three rather than asking a
|
|
169
|
+
* new one:
|
|
170
|
+
*
|
|
171
|
+
* - `systems:` (#48) declares them without requiring one, which is how a
|
|
172
|
+
* package ships for several;
|
|
173
|
+
* - a **pack's** `system:` is the same statement made per pack, and it is the
|
|
174
|
+
* one some trees make: `harn-ensemble` declares an `actors-sohl` and an
|
|
175
|
+
* `actors-hm3` and nothing else about either system. It is already
|
|
176
|
+
* authoritative elsewhere — `eligibleFor` fails a note for want of the block
|
|
177
|
+
* a pack's `system:` names — so a lint that did not read it would refuse a
|
|
178
|
+
* note at compile for a block it never checked;
|
|
179
|
+
* - `stats.systemId` is the package-wide answer where there is one, and it has
|
|
180
|
+
* already absorbed every way of spelling that: a system package is its own
|
|
181
|
+
* system, and a module takes `requiresSystem`, its lone `systems:` entry, or
|
|
182
|
+
* its lone system relationship.
|
|
183
|
+
*
|
|
184
|
+
* A package naming a system in none of them is system-agnostic on purpose — its
|
|
185
|
+
* packs are core document types carrying no system data — so it carries no
|
|
186
|
+
* system block and naming one would invent it.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} [config] - A resolved configuration from `defineConfig`.
|
|
189
|
+
* @returns {string[]} The system ids, deduplicated, in declared order.
|
|
190
|
+
*/
|
|
191
|
+
export function declaredSystems(config) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const system of Object.keys(config?.systems ?? {})) {
|
|
194
|
+
if (!out.includes(system)) out.push(system);
|
|
195
|
+
}
|
|
196
|
+
for (const pack of config?.packs ?? []) {
|
|
197
|
+
const system = pack?.system;
|
|
198
|
+
if (typeof system === "string" && system && !out.includes(system)) out.push(system);
|
|
199
|
+
}
|
|
200
|
+
if (out.length) return out;
|
|
201
|
+
const packageWide = config?.stats?.systemId;
|
|
202
|
+
return typeof packageWide === "string" && packageWide ? [packageWide] : [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The system blocks a configuration says its tree carries, and what each
|
|
207
|
+
* accepts (#58).
|
|
208
|
+
*
|
|
209
|
+
* The lint checks the blocks its caller names, and for as long as there was one
|
|
210
|
+
* system the only caller named none — so every tree took the `sohl:` of
|
|
211
|
+
* {@link DEFAULT_SYSTEM_BLOCKS}, a constant, in a module whose whole discipline
|
|
212
|
+
* is that it states no vocabulary of its own. That is wrong in both directions
|
|
213
|
+
* the moment a second system exists, and the second direction is the worse:
|
|
214
|
+
*
|
|
215
|
+
* - a package shipping for `hm3` had its `hm3:` block **never looked at**, so
|
|
216
|
+
* every key in it was discarded at compile without a word — the silent-drop
|
|
217
|
+
* family this check exists to close;
|
|
218
|
+
* - and the block that *was* checked was named after a system that package does
|
|
219
|
+
* not ship for, so the one finding it could make was about nothing.
|
|
220
|
+
*
|
|
221
|
+
* **A block's vocabulary has two sources, and a system may have both.**
|
|
222
|
+
*
|
|
223
|
+
* - The **note schemas** the caller hands in as `schemas`. Those belong to one
|
|
224
|
+
* system — the CLI imports `sohl/note-schemas.mjs` — and `schemaSystem` is the
|
|
225
|
+
* caller naming which, because only the caller knows. It is the only source
|
|
226
|
+
* that reaches a type no item registry declares, which is to say `being`: the
|
|
227
|
+
* 2,512 notes `harn-ensemble` is made of, and the reason this is not an
|
|
228
|
+
* optional refinement.
|
|
229
|
+
* - The system's **own registry**, `itemFieldsBySystem`, keyed by system and
|
|
230
|
+
* until now read by nothing. This is what a *second* system's block is held
|
|
231
|
+
* to, since the note schemas describe its neighbour.
|
|
232
|
+
*
|
|
233
|
+
* A system with neither is left out: nothing can state what its block may
|
|
234
|
+
* carry, and holding it to an empty vocabulary would report every key in a
|
|
235
|
+
* correct tree. **That is a check that does not run**, which is
|
|
236
|
+
* indistinguishable from one that passed, so the caller says it out loud —
|
|
237
|
+
* {@link declaredSystems} is the other half of that comparison. `harn-ensemble`
|
|
238
|
+
* is the tree it names: two systems, and an `itemBuilders` registry for
|
|
239
|
+
* neither, so its `hm3:` block is unchecked until it declares one.
|
|
240
|
+
*
|
|
241
|
+
* An earlier draft of this took the note schemas for a system's vocabulary only
|
|
242
|
+
* where the package declared **one** system, on the reasoning that with several
|
|
243
|
+
* there is nothing to say which one they describe. There is: the caller, which
|
|
244
|
+
* chose them. The guess cost `harn-ensemble` its whole `sohl:` check — two
|
|
245
|
+
* systems declared, so the fallback never fired — which is the coverage this
|
|
246
|
+
* change exists to widen rather than narrow.
|
|
247
|
+
*
|
|
248
|
+
* @param {object} [config] - A resolved configuration from `defineConfig`.
|
|
249
|
+
* @param {object} [options] - Options.
|
|
250
|
+
* @param {string} [options.schemaSystem] - The system whose vocabulary the
|
|
251
|
+
* caller's `schemas` state. There are two systems, not an open set, so this is
|
|
252
|
+
* one word from the caller rather than a mechanism.
|
|
253
|
+
* @returns {Readonly<Record<string, SystemBlockSpec>>} The blocks to check, in
|
|
254
|
+
* declared order. A system nothing states the vocabulary of is absent.
|
|
255
|
+
*/
|
|
256
|
+
export function systemBlocksFor(config, { schemaSystem } = {}) {
|
|
257
|
+
const byName = config?.itemFieldsBySystem ?? {};
|
|
258
|
+
/** @type {Record<string, SystemBlockSpec>} */
|
|
259
|
+
const blocks = {};
|
|
260
|
+
for (const system of declaredSystems(config)) {
|
|
261
|
+
/** @type {SystemBlockSpec} */
|
|
262
|
+
const spec = {};
|
|
263
|
+
if (system === schemaSystem) spec.fieldVocabulary = true;
|
|
264
|
+
if (byName[system]) spec.fields = byName[system];
|
|
265
|
+
if (Object.keys(spec).length) blocks[system] = Object.freeze(spec);
|
|
266
|
+
}
|
|
267
|
+
return Object.freeze(blocks);
|
|
268
|
+
}
|
|
269
|
+
|
|
142
270
|
/**
|
|
143
271
|
* Edit distance, capped — enough to answer "did you mean".
|
|
144
272
|
*
|
|
@@ -591,30 +719,114 @@ function checkTags(note, { type }) {
|
|
|
591
719
|
* note in any tree writes `img: ""` on a being; a check keyed on `img` alone
|
|
592
720
|
* would have called that tree clean (#218).
|
|
593
721
|
*
|
|
594
|
-
*
|
|
722
|
+
* **Each carries where it is authored**, because the two no longer agree. The
|
|
723
|
+
* specification puts an actor's portrait under `data:` and leaves its token art
|
|
724
|
+
* at the note's top level, so `portrait` has a third position to read and `img`
|
|
725
|
+
* does not — and a check that read only the two they share would pass a
|
|
726
|
+
* `data.portrait: ""` it could not see (#332).
|
|
727
|
+
*
|
|
728
|
+
* @type {readonly {key: string, inData: boolean}[]}
|
|
729
|
+
*/
|
|
730
|
+
const ART_FIELDS = Object.freeze([
|
|
731
|
+
Object.freeze({ key: "img", inData: false }),
|
|
732
|
+
Object.freeze({ key: "portrait", inData: true }),
|
|
733
|
+
]);
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* The in-block keys a type's own declarations claim for a *different* quantity.
|
|
737
|
+
*
|
|
738
|
+
* {@link module:engine/field-spec.FieldSpec.topLevelMeans} read from the other
|
|
739
|
+
* side. That property says the note's top-level key of a field's name means
|
|
740
|
+
* something else, and `resolveFieldValue` honours it by refusing to read the
|
|
741
|
+
* shared position *for that field*. The statement is symmetric: if the two
|
|
742
|
+
* positions hold unrelated quantities, then the **block** position is not the
|
|
743
|
+
* note-level field either, and a check about the note-level field must not read
|
|
744
|
+
* it.
|
|
745
|
+
*
|
|
746
|
+
* `affiliation`'s `title` is the case that named this. A note's top-level
|
|
747
|
+
* `title` is its page heading, which the site emitter publishes as
|
|
748
|
+
* `fm.title ?? name`; `sohl.title` is the style of address an office carries —
|
|
749
|
+
* "Ajaw", "Warden". Twenty-eight `sohl-kethira-basic` affiliations author
|
|
750
|
+
* `sohl.title: ""` — an office with no style of address, which is ordinary —
|
|
751
|
+
* and every one of them was reported as publishing a page with no heading. None
|
|
752
|
+
* of them does; their pages take `name.full` exactly as intended (#312).
|
|
753
|
+
*
|
|
754
|
+
* Keyed on the **in-block** key — `legacyKey` where a field declares one, and
|
|
755
|
+
* its first segment where that is dotted — because that is the position a note
|
|
756
|
+
* authors, and so the position a note-level check would otherwise read.
|
|
757
|
+
*
|
|
758
|
+
* @param {readonly object[]|null|undefined} schema - The type's declarations.
|
|
759
|
+
* @returns {Set<string>} The in-block keys that are not the note-level field of
|
|
760
|
+
* the same name.
|
|
595
761
|
*/
|
|
596
|
-
|
|
762
|
+
/**
|
|
763
|
+
* The keys a field declaration is authored at **inside a system block**.
|
|
764
|
+
*
|
|
765
|
+
* The first segment of each field's in-block key: `impact.die` is authored as
|
|
766
|
+
* `impact`, and a field whose shared source moved under `data:` is authored at
|
|
767
|
+
* the `legacyKey` it declares rather than at its dotted name (#305). Keying on
|
|
768
|
+
* the name instead would report `sohl.species` as a property no `being` has,
|
|
769
|
+
* against exactly the notes the sweep has not reached yet.
|
|
770
|
+
*
|
|
771
|
+
* Written once and read twice: the note type's own declaration answers for the
|
|
772
|
+
* system whose vocabulary the caller's `schemas` are, and a second system's
|
|
773
|
+
* registry answers for its block (#58). Two derivations of one thing would be
|
|
774
|
+
* free to disagree about which position a note authors.
|
|
775
|
+
*
|
|
776
|
+
* @param {readonly object[]|null|undefined} schema - A type's declarations.
|
|
777
|
+
* @returns {Set<string>} The in-block keys.
|
|
778
|
+
*/
|
|
779
|
+
function inBlockKeys(schema) {
|
|
780
|
+
return new Set(authoredFields(schema ?? []).map((f) => legacyKeyOf(f).split(".")[0]));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function collidingBlockKeys(schema) {
|
|
784
|
+
const keys = new Set();
|
|
785
|
+
if (!Array.isArray(schema)) return keys;
|
|
786
|
+
for (const field of authoredFields(schema)) {
|
|
787
|
+
if (field.topLevelMeans === undefined) continue;
|
|
788
|
+
keys.add(String(legacyKeyOf(field)).split(".")[0]);
|
|
789
|
+
}
|
|
790
|
+
return keys;
|
|
791
|
+
}
|
|
597
792
|
|
|
598
793
|
/**
|
|
599
|
-
* Read a shared
|
|
600
|
-
*
|
|
794
|
+
* Read a shared field the way the compiler reads one: the `sohl:` block first,
|
|
795
|
+
* then `data:` where the field lives there, then the note's top level.
|
|
796
|
+
*
|
|
797
|
+
* The same order {@link module:engine/system-block.resolveFieldValue} uses,
|
|
798
|
+
* restated here rather than imported so this module stays a leaf the linter can
|
|
799
|
+
* load without a resolved build configuration. Unlike that resolver it
|
|
800
|
+
* distinguishes the two empties — an authored `""` comes back as `""` and an
|
|
801
|
+
* authored `null` as `null` — which is the whole point of the caller below
|
|
802
|
+
* (#218).
|
|
601
803
|
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
804
|
+
* **`blockCollides` drops the first position**, where the note's type declares
|
|
805
|
+
* a system field of that name meaning something else — the resolver's
|
|
806
|
+
* `topLevelMeans` exemption, applied from the note-level side (#312). See
|
|
807
|
+
* {@link collidingBlockKeys}. The caller decides per key rather than this
|
|
808
|
+
* function deciding for itself, because this module knows no type's vocabulary:
|
|
809
|
+
* the declarations arrive from the caller, as `schemas` and `vocabulary` do.
|
|
607
810
|
*
|
|
608
811
|
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
609
812
|
* @param {string} key - The field name.
|
|
610
|
-
* @
|
|
611
|
-
*
|
|
813
|
+
* @param {object} [options] - Options.
|
|
814
|
+
* @param {boolean} [options.inData=false] - Whether the field's shared source
|
|
815
|
+
* is `data.<key>` rather than the top-level key.
|
|
816
|
+
* @param {boolean} [options.blockCollides=false] - Whether `sohl.<key>` is a
|
|
817
|
+
* system field that merely shares this name, and so answers for nothing here.
|
|
818
|
+
* @returns {any} The authored value, or `undefined` where no position declares
|
|
819
|
+
* one.
|
|
612
820
|
*/
|
|
613
|
-
function authoredValue(fm, key) {
|
|
614
|
-
const block = fm?.sohl;
|
|
821
|
+
function authoredValue(fm, key, { inData = false, blockCollides = false } = {}) {
|
|
822
|
+
const block = blockCollides ? undefined : fm?.sohl;
|
|
615
823
|
if (block && typeof block === "object" && !Array.isArray(block) && Object.hasOwn(block, key)) {
|
|
616
824
|
return block[key];
|
|
617
825
|
}
|
|
826
|
+
const data = inData ? fm?.data : undefined;
|
|
827
|
+
if (data && typeof data === "object" && !Array.isArray(data) && Object.hasOwn(data, key)) {
|
|
828
|
+
return data[key];
|
|
829
|
+
}
|
|
618
830
|
return fm && Object.hasOwn(fm, key) ? fm[key] : undefined;
|
|
619
831
|
}
|
|
620
832
|
|
|
@@ -709,9 +921,17 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
709
921
|
* note against whatever its type declares and knows no type names of its
|
|
710
922
|
* own. Its absence skips the `data:` and `subType` checks rather than
|
|
711
923
|
* reporting every key as unknown.
|
|
712
|
-
* @param {
|
|
713
|
-
*
|
|
714
|
-
*
|
|
924
|
+
* @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
|
|
925
|
+
* What art a note of one type reaches its document through — the passes' own
|
|
926
|
+
* declaration, asked through `engine/generate.mjs`'s `emittedArtFor`.
|
|
927
|
+
* Supplied by the caller like `schemas`, so this module states no list of
|
|
928
|
+
* iconless types of its own; absent it, an inert `img:` goes unreported
|
|
929
|
+
* rather than every note's being (#349).
|
|
930
|
+
* @param {Readonly<Record<string, SystemBlockSpec>>} [opts.systems]
|
|
931
|
+
* The system blocks to check, and what each accepts. Supplied by the caller
|
|
932
|
+
* for the same reason `schemas` is — a build derives them from its
|
|
933
|
+
* configuration through {@link systemBlocksFor}, and this module states no
|
|
934
|
+
* system name of its own. See {@link DEFAULT_SYSTEM_BLOCKS} for the fallback.
|
|
715
935
|
* @param {readonly string[]} [opts.packs] - The pack names this package
|
|
716
936
|
* declares, for a `data:` field whose map is keyed by pack. Supplied by the
|
|
717
937
|
* caller like `schemas` and `vocabulary`, and absent it no claim is made
|
|
@@ -720,13 +940,21 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
720
940
|
*/
|
|
721
941
|
export function lintNote(
|
|
722
942
|
note,
|
|
723
|
-
{ schemas, index, vocabulary, packs, systems = DEFAULT_SYSTEM_BLOCKS },
|
|
943
|
+
{ schemas, index, vocabulary, packs, emittedArt, systems = DEFAULT_SYSTEM_BLOCKS },
|
|
724
944
|
) {
|
|
725
945
|
const findings = [];
|
|
726
946
|
const fm = note.fm ?? {};
|
|
727
947
|
const type = String(fm.type ?? "");
|
|
728
948
|
const raw = () => note.raw ?? "";
|
|
729
949
|
const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
|
|
950
|
+
/**
|
|
951
|
+
* The in-block keys this note's own type claims for something other than
|
|
952
|
+
* the note-level field of that name, which every note-level check below
|
|
953
|
+
* reads past (#312). Resolved once: the type is fixed for the note, and
|
|
954
|
+
* each check would otherwise ask the same question of the same
|
|
955
|
+
* declarations.
|
|
956
|
+
*/
|
|
957
|
+
const blockCollisions = collidingBlockKeys(schemas?.[currentType(type)]);
|
|
730
958
|
|
|
731
959
|
// The retired top-level fields, checked before the type: a note may carry
|
|
732
960
|
// one whatever its type is, and each finding stands on its own. Reported
|
|
@@ -797,15 +1025,23 @@ export function lintNote(
|
|
|
797
1025
|
// optional strings, and it is not — it belongs to `resolveImg`, and `title`
|
|
798
1026
|
// never goes through it.
|
|
799
1027
|
//
|
|
800
|
-
// It once had a sharper reason
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
1028
|
+
// It once had a sharper reason: a note's top-level `title` was
|
|
1029
|
+
// simultaneously the shared source for an `affiliation` item's
|
|
1030
|
+
// `system.title`, so asking an author for `title: null` would have compiled
|
|
1031
|
+
// the literal string `"null"` into the document. The field declares
|
|
1032
|
+
// `topLevelMeans` now, so the top-level key is no longer a source for it and
|
|
1033
|
+
// `title: null` is harmless. `title: ""` is warned about on its own account
|
|
1034
|
+
// below, as the *page's* heading rather than as an art path.
|
|
1035
|
+
//
|
|
1036
|
+
// **The collision itself did not go away, and this was where that was
|
|
1037
|
+
// misread.** `topLevelMeans` settles which position the *emitted field*
|
|
1038
|
+
// reads; it says nothing about which position a *check* reads, and
|
|
1039
|
+
// `authoredValue` went on resolving through the block regardless — so an
|
|
1040
|
+
// office with no style of address answered for its note's heading, in
|
|
1041
|
+
// twenty-eight `sohl-kethira-basic` affiliations (#312). Hence
|
|
1042
|
+
// `blockCollisions`: a note-level check reads past a block key its type
|
|
1043
|
+
// claims for something else.
|
|
1044
|
+
|
|
809
1045
|
// The template priority is a *shared source* — the specification states it
|
|
810
1046
|
// once for every type, as it does `pack` — so its retirement is reported
|
|
811
1047
|
// here rather than by the per-type loop below, which only reaches a field
|
|
@@ -829,8 +1065,70 @@ export function lintNote(
|
|
|
829
1065
|
});
|
|
830
1066
|
}
|
|
831
1067
|
|
|
832
|
-
|
|
833
|
-
|
|
1068
|
+
// An art field a note's own type never emits (#349). `img` is a *shared
|
|
1069
|
+
// top-level* field — `BLOCK_DOCUMENT_PROPERTIES` maps it onto
|
|
1070
|
+
// `document.img`, so it is legal on every note whatever the type — and a
|
|
1071
|
+
// note whose document has no such property authors it, validates, compiles,
|
|
1072
|
+
// and loses the value with nothing said. `Parrot` in `sohl-thalorna` had
|
|
1073
|
+
// declared `img: images/mystery/parrot.webp` since long before the art rule
|
|
1074
|
+
// was written and compiled `img: null`, exactly as a note declaring nothing
|
|
1075
|
+
// does. The author's only evidence was the absence of an icon somewhere
|
|
1076
|
+
// they were probably not looking.
|
|
1077
|
+
//
|
|
1078
|
+
// **Which types those are is not stated here.** It is asked of the passes,
|
|
1079
|
+
// through the `emittedArt` the caller supplies — a note's type routes to a
|
|
1080
|
+
// document, a document to the pass that compiles it, and the pass declares
|
|
1081
|
+
// its own art. A list of iconless types kept in the linter would be a list
|
|
1082
|
+
// free to drift from what is actually emitted, which is the defect rather
|
|
1083
|
+
// than the check. Absent the option no claim is made, on the pattern
|
|
1084
|
+
// `index` and `vocabulary` set.
|
|
1085
|
+
//
|
|
1086
|
+
// **Only an authored value, never `null`.** `null` is the blessed spelling
|
|
1087
|
+
// for "this note names no art" (#218), and on a type with no art that is a
|
|
1088
|
+
// true and harmless thing to say — it compiles identically to writing
|
|
1089
|
+
// nothing. Twenty-six `sohl-thalorna` place notes are in exactly that
|
|
1090
|
+
// state, and telling each of them to delete a key that already means
|
|
1091
|
+
// nothing would bury the fifty-seven that name a path they believe ships.
|
|
1092
|
+
//
|
|
1093
|
+
// A **warning**, as the `package:` and retired-alias sweeps are: the note
|
|
1094
|
+
// compiles correctly and the value is merely inert. Nor is it certainly
|
|
1095
|
+
// unwanted — a note's top level is the generated page's front matter as
|
|
1096
|
+
// well, so a template may read there what no document carries, which is a
|
|
1097
|
+
// judgement only the tree's author can make.
|
|
1098
|
+
const emitted = emittedArt ? emittedArt(currentType(type)) : null;
|
|
1099
|
+
/** The art fields this note's type reaches nothing through. */
|
|
1100
|
+
const inertArt = new Set(
|
|
1101
|
+
emitted ? ART_FIELDS.filter(({ key }) => !emitted.art.includes(key)).map((f) => f.key) : [],
|
|
1102
|
+
);
|
|
1103
|
+
for (const { key, inData } of ART_FIELDS) {
|
|
1104
|
+
if (!inertArt.has(key)) continue;
|
|
1105
|
+
const authored = authoredValue(fm, key, {
|
|
1106
|
+
inData,
|
|
1107
|
+
blockCollides: blockCollisions.has(key),
|
|
1108
|
+
});
|
|
1109
|
+
if (typeof authored !== "string") continue;
|
|
1110
|
+
findings.push({
|
|
1111
|
+
file: note.file,
|
|
1112
|
+
...at(key),
|
|
1113
|
+
severity: "warning",
|
|
1114
|
+
message:
|
|
1115
|
+
`\`${key}:\` reaches no document from a \`${type}\` note — ` +
|
|
1116
|
+
(emitted?.document ?
|
|
1117
|
+
`it compiles into a ${emitted.document}, which carries no artwork`
|
|
1118
|
+
: "it compiles into a page rather than a compendium document") +
|
|
1119
|
+
", so the path is dropped. Delete the key, or move the art onto " +
|
|
1120
|
+
"the note whose document is meant to show it; keep it only where " +
|
|
1121
|
+
"a page template reads it as a parameter",
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
for (const { key, inData } of ART_FIELDS) {
|
|
1126
|
+
// Reported above, and the distinction this draws does not exist there:
|
|
1127
|
+
// where nothing is emitted, `""` and `null` are equally inert and the
|
|
1128
|
+
// note has no default art to lose.
|
|
1129
|
+
if (inertArt.has(key)) continue;
|
|
1130
|
+
if (authoredValue(fm, key, { inData, blockCollides: blockCollisions.has(key) }) !== "")
|
|
1131
|
+
continue;
|
|
834
1132
|
findings.push({
|
|
835
1133
|
file: note.file,
|
|
836
1134
|
...at(key),
|
|
@@ -852,7 +1150,13 @@ export function lintNote(
|
|
|
852
1150
|
//
|
|
853
1151
|
// A warning rather than an error: the value is legal under the rule, and a
|
|
854
1152
|
// note that genuinely wants no heading may keep it — it just has to mean it.
|
|
855
|
-
|
|
1153
|
+
//
|
|
1154
|
+
// **The emitter reads `fm.title`, so this reads the note level.** On an
|
|
1155
|
+
// `affiliation` `sohl.title` is the office's style of address, which the
|
|
1156
|
+
// heading has nothing to do with — and `blockCollisions` is what keeps the
|
|
1157
|
+
// two apart (#312). On every other type nothing claims the block key, so the
|
|
1158
|
+
// resolution is the unchanged one.
|
|
1159
|
+
if (authoredValue(fm, "title", { blockCollides: blockCollisions.has("title") }) === "") {
|
|
856
1160
|
findings.push({
|
|
857
1161
|
file: note.file,
|
|
858
1162
|
...at("title"),
|
|
@@ -1016,15 +1320,8 @@ export function lintNote(
|
|
|
1016
1320
|
}
|
|
1017
1321
|
|
|
1018
1322
|
const fields = authoredFields(schema);
|
|
1019
|
-
/**
|
|
1020
|
-
|
|
1021
|
-
* `impact.die` is authored as `impact`, and a field whose shared source
|
|
1022
|
-
* moved under `data:` is authored at the `legacyKey` it declares rather
|
|
1023
|
-
* than at its dotted name (#305). Keying this on the name would report
|
|
1024
|
-
* `sohl.species` as a property no `being` has, against exactly the notes
|
|
1025
|
-
* the sweep has not reached yet.
|
|
1026
|
-
*/
|
|
1027
|
-
const declared = new Set(fields.map((f) => legacyKeyOf(f).split(".")[0]));
|
|
1323
|
+
/** The keys this type's own declaration is authored at inside a block. */
|
|
1324
|
+
const declared = inBlockKeys(schema);
|
|
1028
1325
|
|
|
1029
1326
|
// The retired spelling of a field this type declares → what to write now.
|
|
1030
1327
|
// Built from the type's own vocabulary, so a renamed field is retired
|
|
@@ -1056,16 +1353,33 @@ export function lintNote(
|
|
|
1056
1353
|
// Every declared system's block, each against its own vocabulary (#58). A
|
|
1057
1354
|
// block carries the shared keys any system's does — `system`, `type`,
|
|
1058
1355
|
// `img`, `effects`, `flags`, `pack` — plus whatever that system declares:
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1356
|
+
// the note type's own field names for the system those schemas describe,
|
|
1357
|
+
// and a second system's own registry for its block. Which systems arrive
|
|
1358
|
+
// here is the configuration's answer, not this module's; see
|
|
1359
|
+
// {@link systemBlocksFor}.
|
|
1061
1360
|
for (const [blockName, spec] of Object.entries(systems ?? {})) {
|
|
1062
1361
|
// Two embedded items denoting one entity (#228). Per block, because
|
|
1063
1362
|
// `items` is a block key and a second system's actor carries its own.
|
|
1363
|
+
// Before the `continue` below, because it is a statement about the
|
|
1364
|
+
// block's *shape* and holds whether or not this system declares a
|
|
1365
|
+
// vocabulary for the note's type.
|
|
1064
1366
|
findings.push(...checkEmbeddedShortcodes(note, blockName));
|
|
1367
|
+
// A block is checked only where its system speaks about this type. A
|
|
1368
|
+
// type a system's registry does not name is a type it says nothing
|
|
1369
|
+
// about — SoHL's `mysticalability` is not an HM3 type at all — and
|
|
1370
|
+
// holding the block to an empty vocabulary would report every key in
|
|
1371
|
+
// it, which is the correct tree reported red.
|
|
1372
|
+
//
|
|
1373
|
+
// `fieldVocabulary` reaches types no registry declares, `being` above
|
|
1374
|
+
// all, so a spec carrying it always speaks. Only a spec whose *sole*
|
|
1375
|
+
// statement is `fields` can fall silent here.
|
|
1376
|
+
const own = spec?.fields;
|
|
1377
|
+
if (own && !own[type] && !spec?.fieldVocabulary && !spec?.known) continue;
|
|
1065
1378
|
const accepted = new Set([
|
|
1066
1379
|
...UNIVERSAL_KEYS,
|
|
1067
1380
|
...(spec?.known ?? []),
|
|
1068
1381
|
...(spec?.fieldVocabulary ? declared : []),
|
|
1382
|
+
...(own ? inBlockKeys(own[type]) : []),
|
|
1069
1383
|
]);
|
|
1070
1384
|
for (const key of unknownBlockKeys(fm, blockName, { known: accepted })) {
|
|
1071
1385
|
// Reported above, with what to write instead — a retired spelling
|
|
@@ -1194,14 +1508,20 @@ export function lintNote(
|
|
|
1194
1508
|
* @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
|
|
1195
1509
|
* it declares (#128); see {@link lintNote}.
|
|
1196
1510
|
* @param {boolean} [opts.references=true] - Whether to check references.
|
|
1197
|
-
* @param {Readonly<Record<string,
|
|
1198
|
-
* The system blocks to check
|
|
1511
|
+
* @param {Readonly<Record<string, SystemBlockSpec>>} [opts.systems]
|
|
1512
|
+
* The system blocks to check; see {@link lintNote} and
|
|
1513
|
+
* {@link systemBlocksFor}.
|
|
1199
1514
|
* @param {readonly string[]} [opts.packs] - The declared pack names; see
|
|
1200
1515
|
* {@link lintNote}.
|
|
1516
|
+
* @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
|
|
1517
|
+
* What art a type reaches its document through; see {@link lintNote}.
|
|
1201
1518
|
* @returns {{findings: object[], notes: number}} The findings, and how many
|
|
1202
1519
|
* notes were inspected.
|
|
1203
1520
|
*/
|
|
1204
|
-
export function lintFrontmatter(
|
|
1521
|
+
export function lintFrontmatter(
|
|
1522
|
+
index,
|
|
1523
|
+
{ schemas, vocabulary, packs, emittedArt, references = true, systems },
|
|
1524
|
+
) {
|
|
1205
1525
|
const findings = [];
|
|
1206
1526
|
const notes = [...index.notes].sort((a, b) =>
|
|
1207
1527
|
a.file < b.file ? -1
|
|
@@ -1214,6 +1534,7 @@ export function lintFrontmatter(index, { schemas, vocabulary, packs, references
|
|
|
1214
1534
|
schemas,
|
|
1215
1535
|
vocabulary,
|
|
1216
1536
|
packs,
|
|
1537
|
+
emittedArt,
|
|
1217
1538
|
index: references ? index : undefined,
|
|
1218
1539
|
...(systems ? { systems } : {}),
|
|
1219
1540
|
}),
|
package/engine/frontmatter.mjs
CHANGED
|
@@ -301,13 +301,15 @@ export function parseValueDesc(raw) {
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
/**
|
|
304
|
-
* The compendium folder a note names
|
|
304
|
+
* The compendium folder a note names.
|
|
305
305
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
306
|
+
* **There is one spelling.** `packFolder:` is a folder note's **address**
|
|
307
|
+
* (`folder-poisonsandtoxins`), resolved through the address index the whole
|
|
308
|
+
* build shares. The `folder:` Foundry-id spelling this function once read
|
|
309
|
+
* beside it, and the per-pack `*-folders.yaml` that id was resolved against,
|
|
310
|
+
* are retired together (#260) — a note declaring `folder:` is refused by
|
|
311
|
+
* {@link module:engine/folder-notes.assertNoDeclaredFolder} rather than
|
|
312
|
+
* reaching here, so there is no second source for a value to come from.
|
|
311
313
|
*
|
|
312
314
|
* **`packFolder` was a path for one release** (`Possessions/Misc_Gear/Cooking`)
|
|
313
315
|
* and is an address now (#255). A path encoded the hierarchy *in the value*, so
|
|
@@ -318,13 +320,11 @@ export function parseValueDesc(raw) {
|
|
|
318
320
|
* authors to migrate, which is the whole reason the change was cheap enough to
|
|
319
321
|
* make.
|
|
320
322
|
*
|
|
321
|
-
* `packFolder` wins where both are present. Nothing about `folder` changes: a
|
|
322
|
-
* note that names one is read, resolved and emitted exactly as before, until
|
|
323
|
-
* #260 retires it.
|
|
324
|
-
*
|
|
325
323
|
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
326
324
|
* @returns {{value: string|null, isAddress: boolean}} The authored value, and
|
|
327
|
-
* whether it is a folder note's address.
|
|
325
|
+
* whether it is a folder note's address. `isAddress` is always `true` and is
|
|
326
|
+
* kept so a caller reads the same shape it always did; it distinguished the
|
|
327
|
+
* two spellings, and there is only one left to be.
|
|
328
328
|
*/
|
|
329
329
|
export function folderField(fm) {
|
|
330
330
|
const asAddress = sohlField(fm, "packFolder", null);
|