@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
|
@@ -63,6 +63,14 @@ import { contentPackage } from "./content-package.mjs";
|
|
|
63
63
|
// inferred from the type itself (#79).
|
|
64
64
|
import { mapsNoteType, noteTypesFor, referencedSubtype } from "./document-subtypes.mjs";
|
|
65
65
|
import { locateFrontmatterKey } from "./retired-fields.mjs";
|
|
66
|
+
// An `items:` entry's `system:` overlay is merged verbatim, so it reaches the
|
|
67
|
+
// document by a path no field declaration sits on — including, until #330, the
|
|
68
|
+
// fields the document is supposed to write for itself in play.
|
|
69
|
+
import { itemFields } from "./item-registry.mjs";
|
|
70
|
+
import { runtimeOnlyIn, runtimeOnlyMessage } from "./runtime-only-fields.mjs";
|
|
71
|
+
// A `model:` is an address, read by the same grammar every wikilink is (#336),
|
|
72
|
+
// so an author writes one form and meets one set of messages.
|
|
73
|
+
import { readQualifier } from "./wikilinks.mjs";
|
|
66
74
|
|
|
67
75
|
/**
|
|
68
76
|
* Strip compendium-only fields from a predefined item before embedding it
|
|
@@ -134,6 +142,61 @@ export function itemAddress(subType, shortcode) {
|
|
|
134
142
|
return `${subType}:${shortcode}`;
|
|
135
143
|
}
|
|
136
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The key one predefined item is held under **for the package that publishes
|
|
147
|
+
* it** — the address a `model:` naming that package resolves through (#334).
|
|
148
|
+
*
|
|
149
|
+
* The unqualified {@link itemAddress} stays beside it, and the two answer
|
|
150
|
+
* different questions. A `model` that names no package means *this* one and
|
|
151
|
+
* takes the unqualified key, where a local definition still shadows a
|
|
152
|
+
* dependency's. A `model` that names a package takes this one, which nothing
|
|
153
|
+
* can shadow: that is the whole point of writing the package down.
|
|
154
|
+
*
|
|
155
|
+
* Not the canonical wikilink address, because this map is keyed in the
|
|
156
|
+
* **document's** vocabulary — a Foundry Item subtype — while a canonical address
|
|
157
|
+
* carries the *note* type. The two differ wherever a system maps a type to a
|
|
158
|
+
* differently-named subtype, and translating here would put the translation in
|
|
159
|
+
* two places.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} pkg - The content package that publishes the item.
|
|
162
|
+
* @param {string} subType - The Foundry Item subtype.
|
|
163
|
+
* @param {string} shortcode - The item's `system.shortcode`.
|
|
164
|
+
* @returns {string} The address, `package:subType:shortcode`.
|
|
165
|
+
*/
|
|
166
|
+
export function packagedItemAddress(pkg, subType, shortcode) {
|
|
167
|
+
return `${pkg}:${subType}:${shortcode}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The key one predefined item is held under **in the catalogue**, with the
|
|
172
|
+
* shortcode folded to lower case.
|
|
173
|
+
*
|
|
174
|
+
* A shortcode is case-sensitive and routinely mixed — `Clb`, `LtShoe`,
|
|
175
|
+
* `HsTunic` — while an **address** is not: `readQualifier` normalises what it
|
|
176
|
+
* reads, and every canonical address is lowercase. So the moment a `model:` is
|
|
177
|
+
* read as an address (#334), `weapongear-clb` has to find the document whose
|
|
178
|
+
* `system.shortcode` is `Clb`, and an exact match cannot (#346).
|
|
179
|
+
*
|
|
180
|
+
* Folding is safe because the fold is already the address: no two items in any
|
|
181
|
+
* published tree differ only by the case of their shortcode, and #340 will make
|
|
182
|
+
* that impossible rather than merely true.
|
|
183
|
+
*
|
|
184
|
+
* **This is not {@link itemAddress}, and must not become it.** That one seeds
|
|
185
|
+
* {@link embeddedItemId}, so folding there would change the `_id` of every
|
|
186
|
+
* embedded item whose identity carries a capital — a silent re-identification of
|
|
187
|
+
* documents nothing about which had changed. The catalogue is a lookup table;
|
|
188
|
+
* an id is a promise.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} subType - The Foundry Item subtype.
|
|
191
|
+
* @param {string} shortcode - The item's `system.shortcode`, in any case.
|
|
192
|
+
* @param {string} [pkg] - The publishing package, for the qualified form.
|
|
193
|
+
* @returns {string} The catalogue key.
|
|
194
|
+
*/
|
|
195
|
+
export function catalogueKey(subType, shortcode, pkg) {
|
|
196
|
+
const folded = String(shortcode).toLowerCase();
|
|
197
|
+
return pkg ? packagedItemAddress(pkg, subType, folded) : itemAddress(subType, folded);
|
|
198
|
+
}
|
|
199
|
+
|
|
137
200
|
/**
|
|
138
201
|
* What identifies one embedded item on its actor.
|
|
139
202
|
*
|
|
@@ -243,7 +306,7 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
|
|
|
243
306
|
}
|
|
244
307
|
const shortcode = doc?.system?.shortcode;
|
|
245
308
|
if (!doc?.type || !shortcode) continue;
|
|
246
|
-
const address =
|
|
309
|
+
const address = catalogueKey(doc.type, shortcode);
|
|
247
310
|
const owner = source.get(address);
|
|
248
311
|
if (owner && owner !== itemsSourceDir) {
|
|
249
312
|
throw new Error(
|
|
@@ -257,9 +320,17 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
|
|
|
257
320
|
// eslint-disable-next-line no-unused-vars
|
|
258
321
|
const { _key, ...rest } = doc;
|
|
259
322
|
map.set(address, rest);
|
|
323
|
+
// And under this package's own name, so a `model:` that names this
|
|
324
|
+
// package explicitly resolves to the same item (#334).
|
|
325
|
+
map.set(catalogueKey(doc.type, shortcode, contentPackage()), rest);
|
|
260
326
|
}
|
|
261
327
|
}
|
|
262
|
-
for (const
|
|
328
|
+
for (const foreignEntry of foreignSourceDirs) {
|
|
329
|
+
// Each dependency's directory arrives with the package that published
|
|
330
|
+
// it (#334), so a foreign template gets its own canonical address
|
|
331
|
+
// rather than sharing the local address space.
|
|
332
|
+
const foreignDir = typeof foreignEntry === "string" ? foreignEntry : foreignEntry.dir;
|
|
333
|
+
const foreignPackage = typeof foreignEntry === "string" ? null : foreignEntry.package;
|
|
263
334
|
for (const name of fs.readdirSync(foreignDir)) {
|
|
264
335
|
if (!name.endsWith(".json")) continue;
|
|
265
336
|
if (name.startsWith("folder_")) continue;
|
|
@@ -277,14 +348,19 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
|
|
|
277
348
|
}
|
|
278
349
|
const shortcode = doc?.system?.shortcode;
|
|
279
350
|
if (!doc?.type || !shortcode) continue;
|
|
280
|
-
const address =
|
|
351
|
+
const address = catalogueKey(doc.type, shortcode);
|
|
352
|
+
// eslint-disable-next-line no-unused-vars
|
|
353
|
+
const { _key, ...rest } = doc;
|
|
354
|
+
// Its own package-qualified address, which a `model:` naming that
|
|
355
|
+
// package resolves through and nothing local can shadow (#334).
|
|
356
|
+
if (foreignPackage) {
|
|
357
|
+
map.set(catalogueKey(doc.type, shortcode, foreignPackage), rest);
|
|
358
|
+
}
|
|
281
359
|
if (map.has(address)) {
|
|
282
360
|
// Deliberate: this repository defines it, so its version wins.
|
|
283
361
|
if (source.has(address)) shadowed.push(address);
|
|
284
362
|
continue;
|
|
285
363
|
}
|
|
286
|
-
// eslint-disable-next-line no-unused-vars
|
|
287
|
-
const { _key, ...rest } = doc;
|
|
288
364
|
map.set(address, rest);
|
|
289
365
|
}
|
|
290
366
|
}
|
|
@@ -310,6 +386,21 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
310
386
|
static id = "actors";
|
|
311
387
|
static label = "actor";
|
|
312
388
|
|
|
389
|
+
/**
|
|
390
|
+
* **Both**, and they are two independent pictures: `img` is the actor's
|
|
391
|
+
* token art — written onto `document.img` and the prototype token's texture
|
|
392
|
+
* — and `portrait` is the sheet portrait, a declared field each system
|
|
393
|
+
* lands under its own name (`system.portrait` for SoHL,
|
|
394
|
+
* `system.bioImage` for HM3).
|
|
395
|
+
*
|
|
396
|
+
* Declared on the shared class because both subclasses emit both. A system
|
|
397
|
+
* whose actor genuinely carried only one would override it here rather than
|
|
398
|
+
* leave the claim standing.
|
|
399
|
+
*
|
|
400
|
+
* @type {readonly string[]}
|
|
401
|
+
*/
|
|
402
|
+
static emitsArt = Object.freeze(["img", "portrait"]);
|
|
403
|
+
|
|
313
404
|
/**
|
|
314
405
|
* Which `(actor, subType:identity)` each resolved entry claimed, and the
|
|
315
406
|
* entry that claimed it first.
|
|
@@ -356,6 +447,20 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
356
447
|
itemsSourceDirs;
|
|
357
448
|
foreignSourceDirs;
|
|
358
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Every package a `model:` may name besides this one — the dependencies
|
|
452
|
+
* whose item catalogues were supplied (#334).
|
|
453
|
+
*
|
|
454
|
+
* @returns {Set<string>} The dependency package ids.
|
|
455
|
+
*/
|
|
456
|
+
get foreignPackages() {
|
|
457
|
+
return new Set(
|
|
458
|
+
(this.foreignSourceDirs ?? [])
|
|
459
|
+
.map((entry) => (typeof entry === "string" ? null : entry?.package))
|
|
460
|
+
.filter(Boolean),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
359
464
|
constructor({ itemsSourceDirs = [], foreignSourceDirs = [], ...options }) {
|
|
360
465
|
super(options);
|
|
361
466
|
// Where the items passes wrote their JSON. Stated by the caller rather
|
|
@@ -498,7 +603,66 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
498
603
|
* @returns {object|null} The embedded item, or null when it resolved to
|
|
499
604
|
* nothing — always with a finding emitted.
|
|
500
605
|
*/
|
|
501
|
-
|
|
606
|
+
/**
|
|
607
|
+
* Read an entry's `model:` — the address of the item it is a copy of.
|
|
608
|
+
*
|
|
609
|
+
* The address grammar is the wikilink one (#336), so a `model` is written at
|
|
610
|
+
* whatever length says what it means: `skill-wpnc` within this package,
|
|
611
|
+
* `sohl-sohl-skill-wpnc` to reach another. The system segment defaults from
|
|
612
|
+
* the block the entry sits in — `<system>.items` — which is what makes the
|
|
613
|
+
* short form name an *Item* here while the same string in body prose names
|
|
614
|
+
* a page.
|
|
615
|
+
*
|
|
616
|
+
* It replaced a top-level `shortcode:` that meant something different from
|
|
617
|
+
* the `system.shortcode` beside it and could not say which package a
|
|
618
|
+
* template came from (#334).
|
|
619
|
+
*
|
|
620
|
+
* @param {unknown} model - The authored value.
|
|
621
|
+
* @param {number} index - The entry's position, for the message.
|
|
622
|
+
* @param {string} ctx - Diagnostic context (the actor's label).
|
|
623
|
+
* @returns {{type: string, shortcode: string, package: string|null}|null}
|
|
624
|
+
* The parsed address, or `null` after reporting why it is not one.
|
|
625
|
+
*/
|
|
626
|
+
readModel(model, index, ctx) {
|
|
627
|
+
const key = `${this.documentSubtypes.block}.items`;
|
|
628
|
+
const where = () =>
|
|
629
|
+
locateFrontmatterKey(this.currentNote?.absPath, "items", String(model ?? ""));
|
|
630
|
+
if (typeof model !== "string" || !model.trim()) {
|
|
631
|
+
this.noteError(`${ctx}: ${key}[${index}] \`model\` must be an address`, where());
|
|
632
|
+
this.errorCount++;
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
// The types this system maps, which are the ones a `model` may name, and
|
|
636
|
+
// every package one may reach: this repository's own plus each
|
|
637
|
+
// dependency whose item catalogue was loaded.
|
|
638
|
+
const types = new Set(Object.keys(this.documentSubtypes.types));
|
|
639
|
+
const packages = new Set([contentPackage(), ...this.foreignPackages]);
|
|
640
|
+
const read = readQualifier(model.trim(), types, packages);
|
|
641
|
+
if (!read || read.reason) {
|
|
642
|
+
const why =
|
|
643
|
+
read?.reason === "not-lowercase" ?
|
|
644
|
+
"capitalises a package, system or type segment — those three " +
|
|
645
|
+
"are lowercase, and only the shortcode keeps its case"
|
|
646
|
+
: read?.reason === "unknown-type" ? "names no known content type"
|
|
647
|
+
: "is not an address — write `type-shortcode`, or " +
|
|
648
|
+
"`package-system-type-shortcode` for another package's item";
|
|
649
|
+
this.noteError(`${ctx}: ${key}[${index}] \`model: ${model}\` ${why}`, where());
|
|
650
|
+
this.errorCount++;
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
return { type: read.type, shortcode: read.shortcode, package: read.package ?? null };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
resolveEmbedded(
|
|
657
|
+
itemsMap,
|
|
658
|
+
actorId,
|
|
659
|
+
type,
|
|
660
|
+
shortcode,
|
|
661
|
+
overlay,
|
|
662
|
+
indexKey,
|
|
663
|
+
ctx,
|
|
664
|
+
{ fmKey, modelPackage = null } = {},
|
|
665
|
+
) {
|
|
502
666
|
// Where a finding about this reference points. The value locates the
|
|
503
667
|
// exact entry in a list; the key is the fallback when it cannot be
|
|
504
668
|
// found, which still beats naming the note alone.
|
|
@@ -511,7 +675,33 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
511
675
|
this.errorCount++;
|
|
512
676
|
return null;
|
|
513
677
|
}
|
|
514
|
-
|
|
678
|
+
// A `model:` may name the package its template comes from (#334). Where
|
|
679
|
+
// it does, the packaged address is used and nothing local can shadow
|
|
680
|
+
// it; where it does not, the unqualified one is, and a local definition
|
|
681
|
+
// still wins over a dependency's as it always has.
|
|
682
|
+
const address = catalogueKey(
|
|
683
|
+
/** @type {string} */ (subType),
|
|
684
|
+
shortcode ?? "",
|
|
685
|
+
modelPackage ?? undefined,
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
// The entry's `system:` overlay is merged verbatim, so it reaches the
|
|
689
|
+
// document without passing a single field declaration — which left it
|
|
690
|
+
// the one position a runtime-only field stayed authorable at once #330
|
|
691
|
+
// closed the item note's own. Asked of the **overlay** rather than of
|
|
692
|
+
// the merged result: the template it merges onto is a compiled
|
|
693
|
+
// document, which by then carries none, and a finding has to name what
|
|
694
|
+
// this note wrote.
|
|
695
|
+
const [runtimeOnly] = runtimeOnlyIn(overlay?.system, itemFields(type, this.system));
|
|
696
|
+
if (runtimeOnly) {
|
|
697
|
+
this.noteError(
|
|
698
|
+
`${ctx}: ${indexKey}: ` +
|
|
699
|
+
`${runtimeOnlyMessage(`${indexKey}.system.${runtimeOnly.to}`, runtimeOnly)}.`,
|
|
700
|
+
where(),
|
|
701
|
+
);
|
|
702
|
+
this.errorCount++;
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
515
705
|
|
|
516
706
|
let base = null;
|
|
517
707
|
if (shortcode) {
|
|
@@ -39,15 +39,33 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* The shape every address segment must match: ASCII letters and
|
|
42
|
+
* The shape every address segment must match: **lowercase** ASCII letters and
|
|
43
|
+
* digits only.
|
|
43
44
|
*
|
|
44
|
-
* Case
|
|
45
|
-
*
|
|
46
|
-
*
|
|
45
|
+
* Case *was* deliberately unconstrained, on the reasoning that case has no
|
|
46
|
+
* bearing on the separator — which is true, and beside the point (#340).
|
|
47
|
+
*
|
|
48
|
+
* **Two names that differ only in case are two names nobody can tell apart.** A
|
|
49
|
+
* shortcode is how a person names a thing when writing a reference —
|
|
50
|
+
* `model: weapongear-dgr`, `[[skill-melee|…]]` — and `Dgr` beside `dgr` is a
|
|
51
|
+
* distinction you cannot say out loud and can only see by looking twice.
|
|
52
|
+
*
|
|
53
|
+
* The toolchain had already half-decided it: {@link canonicalKey} lowercases the
|
|
54
|
+
* address it builds, so a note declaring `Clb` published
|
|
55
|
+
* `sohl-sohl-weapongear-clb` and its `_id` derived from that. The authored name
|
|
56
|
+
* and its address disagreed, and everything downstream keys on the address —
|
|
57
|
+
* which left two notes differing only in case sharing one address, one `_id` and
|
|
58
|
+
* one URL, with nothing to report it. It also forced two exceptions elsewhere:
|
|
59
|
+
* #336 had to exempt the shortcode from the lowercase rule it pinned on every
|
|
60
|
+
* other segment, and #346 had to fold the shortcode's case in the item catalogue
|
|
61
|
+
* because an address is lowercased when read.
|
|
62
|
+
*
|
|
63
|
+
* One case, one spelling, no exceptions. Every tree already complies but two,
|
|
64
|
+
* and nothing in any of them collides when folded.
|
|
47
65
|
*
|
|
48
66
|
* @type {RegExp}
|
|
49
67
|
*/
|
|
50
|
-
export const ADDRESS_SEGMENT_PATTERN = /^[
|
|
68
|
+
export const ADDRESS_SEGMENT_PATTERN = /^[a-z0-9]+$/;
|
|
51
69
|
|
|
52
70
|
/**
|
|
53
71
|
* Whether a value is a well-formed address segment.
|
package/engine/base-compiler.mjs
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
import { isNoteRecord, noteFile } from "./index-records.mjs";
|
|
79
79
|
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
80
80
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
81
|
+
import { assertNoDeclaredFolder } from "./folder-notes.mjs";
|
|
81
82
|
import {
|
|
82
83
|
assertNoAliasesField,
|
|
83
84
|
assertNoDraftField,
|
|
@@ -198,6 +199,34 @@ export class BasePackCompiler {
|
|
|
198
199
|
*/
|
|
199
200
|
static requiresSystemBlock = false;
|
|
200
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The **art fields** this pass reads off a note and writes onto its
|
|
204
|
+
* document — `img`, `portrait`, whichever of them reaches the output.
|
|
205
|
+
*
|
|
206
|
+
* Empty by default, and every shipped pass states its own, for the reason
|
|
207
|
+
* {@link BasePackCompiler.readsPackOutputOf} does: the fact belongs to the
|
|
208
|
+
* class that does the writing, and a second list of it somewhere else is a
|
|
209
|
+
* list free to disagree with what is actually emitted.
|
|
210
|
+
*
|
|
211
|
+
* The reader is the frontmatter lint. `img` is a *shared top-level* field —
|
|
212
|
+
* legal on every note whatever its type, because
|
|
213
|
+
* `BLOCK_DOCUMENT_PROPERTIES` maps it onto `document.img` — so a note whose
|
|
214
|
+
* document has no such property authors it, validates, compiles, and loses
|
|
215
|
+
* the value with nothing said. That is #349: `Parrot` in `sohl-thalorna`
|
|
216
|
+
* had declared `img:` since long before the art rule existed and compiled
|
|
217
|
+
* `img: null` exactly as a note declaring nothing does. Naming the fields
|
|
218
|
+
* here is what lets the lint tell an inert key from a live one.
|
|
219
|
+
*
|
|
220
|
+
* A pass that emits art **anywhere** in its document declares it, not only
|
|
221
|
+
* one that writes a top-level `img`: the scenes pass puts the path on the
|
|
222
|
+
* scene's background rather than on a property called `img`, and the value
|
|
223
|
+
* is no less live for it. The question this answers is whether the authored
|
|
224
|
+
* path reaches the output at all.
|
|
225
|
+
*
|
|
226
|
+
* @type {readonly string[]}
|
|
227
|
+
*/
|
|
228
|
+
static emitsArt = Object.freeze([]);
|
|
229
|
+
|
|
201
230
|
/** @type {string} */
|
|
202
231
|
contentBase;
|
|
203
232
|
/** @type {string} */
|
|
@@ -270,8 +299,9 @@ export class BasePackCompiler {
|
|
|
270
299
|
* @param {string} options.dest - Where this pass writes its JSON.
|
|
271
300
|
* @param {readonly string[]} options.skipDirectories - Directories the walk
|
|
272
301
|
* never descends into. Required: see {@link assertStatedScope}.
|
|
273
|
-
* @param {(
|
|
274
|
-
* Resolves a `
|
|
302
|
+
* @param {(address: string|null) => string|null} [options.folderResolver] -
|
|
303
|
+
* Resolves a `packFolder` — a folder note's address — to the Foundry
|
|
304
|
+
* folder id it materialises as in this pack (#255, #257).
|
|
275
305
|
* @param {string} [options.packName] - The pack this pass writes.
|
|
276
306
|
* @param {string} [options.docType] - The Foundry document type it holds.
|
|
277
307
|
* @param {{resolve: Function}} [options.router] - The pack router. Omit it
|
|
@@ -414,6 +444,31 @@ export class BasePackCompiler {
|
|
|
414
444
|
);
|
|
415
445
|
}
|
|
416
446
|
|
|
447
|
+
/**
|
|
448
|
+
* A refusal only this pass can make, because its subject is the note's
|
|
449
|
+
* **type** (#330).
|
|
450
|
+
*
|
|
451
|
+
* The `assertNo*Field` family above it in the walk is type-agnostic by
|
|
452
|
+
* construction: it runs before `selects`, so that a note declaring a
|
|
453
|
+
* retired field is answered whichever pass would have claimed it. A rule
|
|
454
|
+
* about what a *`trauma`* may write cannot live there — it needs the type's
|
|
455
|
+
* field declaration, which only the pass that compiles the type can reach.
|
|
456
|
+
*
|
|
457
|
+
* So it is a hook, called once the note is known to be this pass's, and its
|
|
458
|
+
* throw is counted and located exactly as the family's is: the note is
|
|
459
|
+
* declined rather than skipped, and the build fails naming the line.
|
|
460
|
+
*
|
|
461
|
+
* The default refuses nothing, which is the honest position for a pass
|
|
462
|
+
* whose documents have no schema to have opinions about.
|
|
463
|
+
*
|
|
464
|
+
* @param {object} fm - The note's frontmatter.
|
|
465
|
+
* @returns {void}
|
|
466
|
+
* @throws {Error} When the note authors something its type forbids. The
|
|
467
|
+
* error may carry a `position` for the diagnostic.
|
|
468
|
+
*/
|
|
469
|
+
// eslint-disable-next-line no-unused-vars
|
|
470
|
+
assertAuthorable(fm) {}
|
|
471
|
+
|
|
417
472
|
/**
|
|
418
473
|
* Whether this pass claims a note. **Required.**
|
|
419
474
|
*
|
|
@@ -895,6 +950,7 @@ export class BasePackCompiler {
|
|
|
895
950
|
// neither message may repeat it.
|
|
896
951
|
try {
|
|
897
952
|
assertNoDeclaredPackage(fm, { absPath });
|
|
953
|
+
assertNoDeclaredFolder(fm, { absPath });
|
|
898
954
|
assertNoDraftField(fm, { absPath });
|
|
899
955
|
assertNoAliasesField(fm, { absPath });
|
|
900
956
|
assertNoSectionField(fm, { absPath });
|
|
@@ -965,6 +1021,13 @@ export class BasePackCompiler {
|
|
|
965
1021
|
stats.skippedOther++;
|
|
966
1022
|
continue;
|
|
967
1023
|
}
|
|
1024
|
+
// The type-specific half of the retired-field family (#330):
|
|
1025
|
+
// what a note of *this* type may not write, which needs the
|
|
1026
|
+
// type's own field declaration and so cannot be asked before
|
|
1027
|
+
// `selects`. Counted as a declined note for the same reason
|
|
1028
|
+
// they are — the alternative is a tree that compiles fewer
|
|
1029
|
+
// documents than it has notes and exits 0.
|
|
1030
|
+
this.assertAuthorable(fm);
|
|
968
1031
|
} catch (err) {
|
|
969
1032
|
stats.declined++;
|
|
970
1033
|
this.errorCount++;
|
package/engine/bundles.mjs
CHANGED
|
@@ -135,6 +135,15 @@ export class Bundles extends BasePackCompiler {
|
|
|
135
135
|
*/
|
|
136
136
|
static readsPackOutputOf = Object.freeze(["Actor", "Item", "JournalEntry", "Macro", "Scene"]);
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* An Adventure carries an `img` — what Foundry shows on the import card.
|
|
140
|
+
* There is no default for it: a bundle naming none ships a blank tile,
|
|
141
|
+
* deliberately, since no stand-in artwork means "a set of documents".
|
|
142
|
+
*
|
|
143
|
+
* @type {readonly string[]}
|
|
144
|
+
*/
|
|
145
|
+
static emitsArt = Object.freeze(["img"]);
|
|
146
|
+
|
|
138
147
|
/**
|
|
139
148
|
* The JSON directories this pass reads its members from, by document type.
|
|
140
149
|
*
|
|
@@ -45,7 +45,9 @@ import { DEFAULT_ADDRESS_SCHEME } from "../content-config.mjs";
|
|
|
45
45
|
// The system vocabulary is the `<system>` segment's own registry, and
|
|
46
46
|
// `engine/systems.mjs` imports nothing but `engine/address-charset.mjs`, so
|
|
47
47
|
// the direction is toward the leaf and cannot close a cycle.
|
|
48
|
-
import { NO_SYSTEM, assertSystemSegment } from "./systems.mjs";
|
|
48
|
+
import { NO_SYSTEM, assertSystemSegment, isSystemSegment } from "./systems.mjs";
|
|
49
|
+
import { systemOf } from "./document-subtypes.mjs";
|
|
50
|
+
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
|
|
49
51
|
|
|
50
52
|
// `ids.mjs` is a leaf with no local imports — the module note there says why —
|
|
51
53
|
// so an address may hash itself without any risk of closing a cycle.
|
|
@@ -203,6 +205,95 @@ export function canonicalKey(pkg, system, type, shortcode) {
|
|
|
203
205
|
return `${pkg}-${system}-${type}-${shortcode}`.toLowerCase();
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Which system a frontmatter key path is written under.
|
|
210
|
+
*
|
|
211
|
+
* The **enclosing system block** decides, at any depth within it, and nothing
|
|
212
|
+
* else does: `sohl.items[3].model` and `sohl.system.body.structure` are both
|
|
213
|
+
* `sohl` because both sit under `sohl:`. Everywhere else is {@link NO_SYSTEM} —
|
|
214
|
+
* top-level frontmatter, the shared `data:` container, and body prose, which has
|
|
215
|
+
* no key path at all and passes `undefined`.
|
|
216
|
+
*
|
|
217
|
+
* It is the block rather than the field, so a `WikiLink` field needs no opinion
|
|
218
|
+
* about systems and no per-field table has to be kept in step with the schema.
|
|
219
|
+
*
|
|
220
|
+
* The first segment must **be** a declared system, not merely look like one:
|
|
221
|
+
* `sohlish.items` is a key called `sohlish`, and `notes.sohl.thing` names no
|
|
222
|
+
* block at all.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} [keyPath] - The dotted frontmatter key path, or `undefined`
|
|
225
|
+
* for body prose.
|
|
226
|
+
* @returns {string} The system id, or `none`.
|
|
227
|
+
*/
|
|
228
|
+
export function blockSystem(keyPath) {
|
|
229
|
+
if (typeof keyPath !== "string" || !keyPath) return NO_SYSTEM;
|
|
230
|
+
const first = keyPath.split(".")[0].trim().toLowerCase();
|
|
231
|
+
return isSystemSegment(first) && first !== NO_SYSTEM ? first : NO_SYSTEM;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Expand a written address to the one canonical address it names.
|
|
236
|
+
*
|
|
237
|
+
* **An omitted segment defaults from where the link is written** (#336) — it is
|
|
238
|
+
* not a wildcard, and resolution is not a search. Package omitted means the
|
|
239
|
+
* citing note's own; system omitted means {@link blockSystem} of the key path it
|
|
240
|
+
* was written under. So every short form has exactly one expansion, computed
|
|
241
|
+
* before anything is looked up, and there is no candidate set to disambiguate.
|
|
242
|
+
*
|
|
243
|
+
* **Under `none`, a system-bearing type addresses its documentation journal.**
|
|
244
|
+
* A note's `none` address *is* its `doc<type>` entry — the Item is the one with
|
|
245
|
+
* a system — so a prose `[[affiliation-sirvadar|…]]` names the page, which is
|
|
246
|
+
* almost always what prose means. A link that means the Item states the system
|
|
247
|
+
* and gets it. This is the defaulting rule applied, not an exception carved out
|
|
248
|
+
* of it.
|
|
249
|
+
*
|
|
250
|
+
* **Only a type whose own document carries a system is redirected.** A `macro`
|
|
251
|
+
* and the map types have documentation journals too, but their own documents
|
|
252
|
+
* are core ones and already live at `none` — so `<pkg>-none-macro-x` names the
|
|
253
|
+
* Macro and `<pkg>-none-docmacro-x` its journal, two live addresses that the
|
|
254
|
+
* redirect would collapse into one. The test is the note type's own system,
|
|
255
|
+
* not merely whether it has a doc entry.
|
|
256
|
+
*
|
|
257
|
+
* A `doc<type>` written explicitly is `none` **wherever** it appears, even
|
|
258
|
+
* inside a system block: no game system defines a JournalEntry, so there is no
|
|
259
|
+
* other system for one to belong to.
|
|
260
|
+
*
|
|
261
|
+
* @param {{type: string, shortcode: string, package?: string, system?: string,
|
|
262
|
+
* itemDoc?: boolean}} read - A qualifier, as `readQualifier` returns one.
|
|
263
|
+
* @param {{package: string, system?: string}} where - The citing context: the
|
|
264
|
+
* tree's own content package, and the system of the block the link sits in.
|
|
265
|
+
* @returns {string} The canonical `package-system-type-shortcode`.
|
|
266
|
+
*/
|
|
267
|
+
export function expandAddress(read, where) {
|
|
268
|
+
const pkg = read.package ?? where.package;
|
|
269
|
+
// A documentation journal is a core document, so it is `none` however it was
|
|
270
|
+
// reached; otherwise the block's system, which body prose reports as `none`.
|
|
271
|
+
const system = read.itemDoc ? NO_SYSTEM : (read.system ?? where.system ?? NO_SYSTEM);
|
|
272
|
+
const redirected = system === NO_SYSTEM && isSystemBearing(read.type);
|
|
273
|
+
const type = read.itemDoc || redirected ? `doc${read.type}` : read.type;
|
|
274
|
+
return canonicalKey(pkg, system, type, read.shortcode);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Whether a note type's **own** document carries a game system.
|
|
279
|
+
*
|
|
280
|
+
* True for the types some shipped map compiles into an Item or an Actor; false
|
|
281
|
+
* for the core-document types — `doc`, `lore`, `place`, `scenario`, `macro` and
|
|
282
|
+
* the map types — whose documents Foundry itself defines and which therefore
|
|
283
|
+
* already live at `none`.
|
|
284
|
+
*
|
|
285
|
+
* It is what {@link expandAddress} tests rather than {@link hasDocEntry}: a
|
|
286
|
+
* `macro` has a documentation journal *and* a `none` address of its own, so
|
|
287
|
+
* redirecting on "has a doc entry" would collapse two live addresses into one
|
|
288
|
+
* and a `[[macro-autoattack|]]` would stop naming the Macro.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} type - The note type.
|
|
291
|
+
* @returns {boolean} True when the type compiles into a system document.
|
|
292
|
+
*/
|
|
293
|
+
function isSystemBearing(type) {
|
|
294
|
+
return systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS) !== NO_SYSTEM;
|
|
295
|
+
}
|
|
296
|
+
|
|
206
297
|
/**
|
|
207
298
|
* How many segments a canonical key has, and therefore how many the reader
|
|
208
299
|
* below counts.
|
|
@@ -46,6 +46,16 @@
|
|
|
46
46
|
* A mapping table's remaining header cells name the systems (`→ sohl`,
|
|
47
47
|
* `→ hm3`), so the system vocabulary comes from the document too.
|
|
48
48
|
*
|
|
49
|
+
* **The other half of a type's vocabulary is a bullet list, not a table.** A
|
|
50
|
+
* type's `subType` values are stated as `**subType**:` followed by one bullet
|
|
51
|
+
* per value, `- <value>` or `- <value>: <definition>`, and that is read here
|
|
52
|
+
* for the same reason the tables are: so the specification and
|
|
53
|
+
* `note-vocabulary.mjs` cannot disagree about which genres exist (#345). The
|
|
54
|
+
* one shape is enforced rather than guessed at — the document wrote them five
|
|
55
|
+
* ways, and a reader that accepted every spelling would accept the sixth by
|
|
56
|
+
* reading the section as declaring nothing, which is the drift it exists to
|
|
57
|
+
* catch. An unrecognised shape throws.
|
|
58
|
+
*
|
|
49
59
|
* **A mapping table before the first `### type:` heading is the shared one.**
|
|
50
60
|
* The document states the rows every type maps identically once, at the top,
|
|
51
61
|
* and omits them from all sixteen per-type tables — so a parser that only ever
|
|
@@ -92,6 +102,9 @@ export const CONTENT_FORMAT_PATH = path.join(
|
|
|
92
102
|
* property — what a note actually writes. `appearance.eye_color` is authored
|
|
93
103
|
* as `appearance`, so that is the key recorded.
|
|
94
104
|
* @property {Set<string>} dataPaths - The declared paths, whole.
|
|
105
|
+
* @property {string[]} subTypes - The `subType` values the section enumerates,
|
|
106
|
+
* in document order — empty when it states none, which is the ordinary case
|
|
107
|
+
* for a type that has no `subType` at all.
|
|
95
108
|
*/
|
|
96
109
|
|
|
97
110
|
/**
|
|
@@ -178,6 +191,79 @@ function columnOfCell(line, index) {
|
|
|
178
191
|
return at + lead + 2;
|
|
179
192
|
}
|
|
180
193
|
|
|
194
|
+
/** The one shape the specification states a type's `subType` values in. */
|
|
195
|
+
const SUBTYPE_MARKER = "**subType**:";
|
|
196
|
+
|
|
197
|
+
/** Any line that reads as a `subType` marker, canonical or not. */
|
|
198
|
+
const SUBTYPE_MARKER_ISH = /^\s*\**\s*subTypes?\s*\**\s*:?\s*$/i;
|
|
199
|
+
|
|
200
|
+
/** One bullet of a values list: `- <value>` or `- <value>: <definition>`. */
|
|
201
|
+
const SUBTYPE_BULLET = /^-\s+(\S+?)\s*(?::|$)/;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A parse failure, positioned where the document went wrong.
|
|
205
|
+
*
|
|
206
|
+
* Thrown rather than collected, because there is nothing partial to report: a
|
|
207
|
+
* marker the reader does not understand yields a section that appears to
|
|
208
|
+
* declare no subTypes, and every comparison against it then passes vacuously
|
|
209
|
+
* (#345). The message carries the compiler-parseable position the rest of the
|
|
210
|
+
* toolchain's diagnostics use.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} file - The document being read.
|
|
213
|
+
* @param {number} line - 1-based line the fault is on.
|
|
214
|
+
* @param {string} message - What is wrong, and what to write instead.
|
|
215
|
+
* @returns {Error} The failure to throw.
|
|
216
|
+
*/
|
|
217
|
+
function specError(file, line, message) {
|
|
218
|
+
return new Error(`${file}:${line}:1: error: ${message}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The `subType` values a section enumerates under its marker.
|
|
223
|
+
*
|
|
224
|
+
* Reads the one contiguous bullet list directly below the marker and stops
|
|
225
|
+
* there: several sections state another closed vocabulary of their own a blank
|
|
226
|
+
* line later — `TransmissionTypes`, `GovernanceModel` — and reading on would
|
|
227
|
+
* quietly attribute its values to `subType`.
|
|
228
|
+
*
|
|
229
|
+
* @param {string[]} lines - The document's lines.
|
|
230
|
+
* @param {number} at - Index of the marker line.
|
|
231
|
+
* @param {string} file - The document, for the failure message.
|
|
232
|
+
* @returns {string[]} The values, in document order.
|
|
233
|
+
*/
|
|
234
|
+
function subTypeValues(lines, at, file) {
|
|
235
|
+
/** @type {string[]} */
|
|
236
|
+
const values = [];
|
|
237
|
+
let i = at + 1;
|
|
238
|
+
while (i < lines.length && lines[i].trim() === "") i += 1;
|
|
239
|
+
for (; i < lines.length; i += 1) {
|
|
240
|
+
const line = lines[i];
|
|
241
|
+
// A wrapped definition is indented under its own bullet.
|
|
242
|
+
if (values.length && /^\s+\S/.test(line)) continue;
|
|
243
|
+
if (!line.startsWith("-")) break;
|
|
244
|
+
const bullet = SUBTYPE_BULLET.exec(line);
|
|
245
|
+
const value = bullet?.[1].replace(/`/g, "");
|
|
246
|
+
if (!value || !/^[A-Za-z0-9]+$/.test(value)) {
|
|
247
|
+
throw specError(
|
|
248
|
+
file,
|
|
249
|
+
i + 1,
|
|
250
|
+
`\`${SUBTYPE_MARKER}\` takes one bullet per value, ` +
|
|
251
|
+
"`- <value>` or `- <value>: <definition>`, and this bullet states none.",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
values.push(value);
|
|
255
|
+
}
|
|
256
|
+
if (!values.length) {
|
|
257
|
+
throw specError(
|
|
258
|
+
file,
|
|
259
|
+
at + 1,
|
|
260
|
+
`\`${SUBTYPE_MARKER}\` enumerates no values. A type whose subType values the ` +
|
|
261
|
+
"specification does not state omits the marker.",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return values;
|
|
265
|
+
}
|
|
266
|
+
|
|
181
267
|
/**
|
|
182
268
|
* Parse the specification's tables.
|
|
183
269
|
*
|
|
@@ -211,12 +297,28 @@ export function parseContentFormat(text, { file = CONTENT_FORMAT_PATH } = {}) {
|
|
|
211
297
|
line: i + 1,
|
|
212
298
|
dataKeys: new Set(),
|
|
213
299
|
dataPaths: new Set(),
|
|
300
|
+
subTypes: [],
|
|
214
301
|
};
|
|
215
302
|
types.set(current.name, current);
|
|
216
303
|
table = undefined;
|
|
217
304
|
continue;
|
|
218
305
|
}
|
|
219
306
|
|
|
307
|
+
if (current && SUBTYPE_MARKER_ISH.test(line)) {
|
|
308
|
+
if (line.trim() !== SUBTYPE_MARKER) {
|
|
309
|
+
throw specError(
|
|
310
|
+
file,
|
|
311
|
+
i + 1,
|
|
312
|
+
`a type's subType values are stated as \`${SUBTYPE_MARKER}\`, ` +
|
|
313
|
+
`not \`${line.trim()}\`. The specification had five spellings and ` +
|
|
314
|
+
"converged on one, so that a section is never read as declaring none.",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
current.subTypes = subTypeValues(lines, i, file);
|
|
318
|
+
table = undefined;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
220
322
|
const cells = cellsOf(line);
|
|
221
323
|
if (!cells) {
|
|
222
324
|
// Any non-table line ends the table. A blank line between two
|