@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
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -46,7 +46,8 @@ import { replaceOutsideCode } from "./code-fences.mjs";
|
|
|
46
46
|
// The canonical `package-system-type-shortcode` key, so a package-qualified
|
|
47
47
|
// address is matched the way a vendored manifest publishes it — by the
|
|
48
48
|
// segments the target supplies, with the system wildcarded unless stated (#59).
|
|
49
|
-
import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
|
|
49
|
+
import { canonicalKey, expandAddress, readCanonicalKey } from "./content-address.mjs";
|
|
50
|
+
import { NO_SYSTEM } from "./systems.mjs";
|
|
50
51
|
// The one rule about a link's shape both builds share: it carries a label, and
|
|
51
52
|
// {@link unlabelledLinkMessage} is the one place that says so (#180).
|
|
52
53
|
import { unlabelledLinkMessage } from "./wikilink-syntax.mjs";
|
|
@@ -85,32 +86,33 @@ import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-s
|
|
|
85
86
|
* @param {object|null} read - From {@link readQualifier}.
|
|
86
87
|
* @returns {string | null} The index key, or `null` when not an address.
|
|
87
88
|
*/
|
|
88
|
-
function lookupRead(index, read) {
|
|
89
|
+
function lookupRead(index, read, contentPackage) {
|
|
89
90
|
if (!read || read.reason) return undefined;
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
91
|
+
// Every omitted segment defaults from where the link is written (#336) —
|
|
92
|
+
// package from the citing package, system from the block, which on a page
|
|
93
|
+
// body is `none` — so the target expands to exactly one canonical address
|
|
94
|
+
// and this is a plain lookup. No filter, no single-hit rule, and no
|
|
95
|
+
// ambiguity: one key names one entry.
|
|
96
|
+
//
|
|
97
|
+
// It replaced a system-blind short key for the unqualified form, which
|
|
98
|
+
// could be silently overwritten by a second note of the same
|
|
99
|
+
// `(type, shortcode)` under another system.
|
|
100
|
+
const key = expandAddress(read, { package: contentPackage, system: NO_SYSTEM });
|
|
101
|
+
const found = index.get(key);
|
|
102
|
+
if (found || read.package) return found;
|
|
94
103
|
|
|
95
|
-
// A
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (parts.package !== pkg) continue;
|
|
108
|
-
if (read.system && parts.system !== String(read.system).toLowerCase()) continue;
|
|
109
|
-
if (parts.type !== type || parts.shortcode !== shortcode) continue;
|
|
110
|
-
if (found) return undefined;
|
|
111
|
-
found = value;
|
|
112
|
-
}
|
|
113
|
-
return found;
|
|
104
|
+
// A local target may also be keyed by its short form. On the KB that is not
|
|
105
|
+
// a second answer: an item note renders as **one page** which is its own
|
|
106
|
+
// documentation, so `skill/climb`, `docskill/climb` and both canonical
|
|
107
|
+
// addresses are all the same value (#1362) — the fallback cannot pick
|
|
108
|
+
// differently, only earlier. It stays because an index built before the
|
|
109
|
+
// canonical documentation key was added still carries the short one, and
|
|
110
|
+
// because a page collision here is caught by the site index's own
|
|
111
|
+
// `ambiguous` set rather than by silent overwrite as in the pack index.
|
|
112
|
+
return (
|
|
113
|
+
index.get(`${read.itemDoc ? "doc" : ""}${read.type}/${read.shortcode}`.toLowerCase()) ??
|
|
114
|
+
undefined
|
|
115
|
+
);
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
/**
|
|
@@ -366,7 +368,7 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
366
368
|
const read = readQualifier(target, ctx.contentTypes ?? new Set(), ctx.packages);
|
|
367
369
|
const rawKey = target.toLowerCase();
|
|
368
370
|
const hit =
|
|
369
|
-
lookupRead(ctx.index, read) ??
|
|
371
|
+
lookupRead(ctx.index, read, ctx.contentPackage) ??
|
|
370
372
|
// `section/slug` is the site's own address for a page, and it is in
|
|
371
373
|
// the same map. Admitted only when the target carries a slash, so
|
|
372
374
|
// a page's bare slug cannot answer for an address.
|
|
@@ -375,7 +377,11 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
375
377
|
// local one (#1446), so a cross-package hit needs no special case
|
|
376
378
|
// below. Local wins: a live build is authoritative and a vendored
|
|
377
379
|
// manifest can only be staler.
|
|
378
|
-
|
|
380
|
+
// A short form names *this* package (#336), so it never reaches a
|
|
381
|
+
// vendored manifest; only a fully qualified address does.
|
|
382
|
+
(ctx.foreign && read?.package ?
|
|
383
|
+
lookupRead(ctx.foreign, read, ctx.contentPackage)
|
|
384
|
+
: undefined);
|
|
379
385
|
if (hit) {
|
|
380
386
|
// An address with an *empty* label has no prose to show (a
|
|
381
387
|
// shortcode is not display text), so the document's **current**
|
|
@@ -182,6 +182,7 @@ export const LINK_FINDING_REASONS = Object.freeze(
|
|
|
182
182
|
new Set([
|
|
183
183
|
"unlabelled",
|
|
184
184
|
"not-an-address",
|
|
185
|
+
"not-lowercase",
|
|
185
186
|
"unknown-type",
|
|
186
187
|
"unresolved",
|
|
187
188
|
"ambiguous",
|
|
@@ -276,6 +277,12 @@ export function linkFindingMessage({ reason, target, packages, anchor }) {
|
|
|
276
277
|
`[[package-system-type-shortcode|Text]] for a note in another ` +
|
|
277
278
|
`package`
|
|
278
279
|
);
|
|
280
|
+
case "not-lowercase":
|
|
281
|
+
return (
|
|
282
|
+
`address [[${target}]] capitalises a package, system or type ` +
|
|
283
|
+
`segment — those three are lowercase, and only the shortcode ` +
|
|
284
|
+
`keeps its case`
|
|
285
|
+
);
|
|
279
286
|
case "unknown-type":
|
|
280
287
|
return `address [[${target}]] names no known content type`;
|
|
281
288
|
case "ambiguous":
|
package/engine/wikilinks.mjs
CHANGED
|
@@ -87,7 +87,9 @@ import crypto from "crypto";
|
|
|
87
87
|
|
|
88
88
|
import { compendiumUuid, ITEM_PACK, packForType, pageUuid, PACK_BY_TYPE } from "./ids.mjs";
|
|
89
89
|
import { readCanonicalKey } from "./content-address.mjs";
|
|
90
|
-
import { isSystemSegment } from "./systems.mjs";
|
|
90
|
+
import { isSystemSegment, NO_SYSTEM } from "./systems.mjs";
|
|
91
|
+
import { systemOf } from "./document-subtypes.mjs";
|
|
92
|
+
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
|
|
91
93
|
import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
92
94
|
import { replaceOutsideCode } from "./code-fences.mjs";
|
|
93
95
|
// The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
|
|
@@ -207,6 +209,48 @@ export function resolveItemDocType(qualifier, types) {
|
|
|
207
209
|
* but names no known type; or `null` when it is not an address at all.
|
|
208
210
|
*/
|
|
209
211
|
export function readQualifier(target, types, packages) {
|
|
212
|
+
// **Package, system and type are lowercase; the shortcode is not.** A
|
|
213
|
+
// shortcode is case-sensitive and routinely mixed — `Clb`, `LtShoe`,
|
|
214
|
+
// `HsTunic` — so it is written as the note declares it. The three segments
|
|
215
|
+
// in front of it are closed vocabularies with one spelling each, and
|
|
216
|
+
// accepting `Skill` beside `skill` would bless two ways of writing one
|
|
217
|
+
// address. Reported rather than folded, so the corpus has one form.
|
|
218
|
+
//
|
|
219
|
+
// Tested only once the target *parses*: a note name is full of capitals
|
|
220
|
+
// (`[[Shock State]]`), and calling that a badly-cased address rather than
|
|
221
|
+
// not an address would name the wrong mistake. Neither tree carries a
|
|
222
|
+
// violation — 10,538 authored targets — so this pins a rule already kept.
|
|
223
|
+
const read = readQualifierCased(target, types, packages);
|
|
224
|
+
if (read && !read.reason && qualifyingSegments(target).some((s) => /[A-Z]/.test(s))) {
|
|
225
|
+
return { reason: "not-lowercase" };
|
|
226
|
+
}
|
|
227
|
+
return read;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The segments of a target that must be lowercase — everything but the
|
|
232
|
+
* shortcode, which is case-sensitive and keeps whatever the note declares.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} target - The link target, anchor already removed.
|
|
235
|
+
* @returns {string[]} The package / system / type segments, as written.
|
|
236
|
+
*/
|
|
237
|
+
function qualifyingSegments(target) {
|
|
238
|
+
const slash = target.lastIndexOf("/");
|
|
239
|
+
// The legacy `type/shortcode` form states only a type.
|
|
240
|
+
if (slash > 0) return [target.slice(0, slash)];
|
|
241
|
+
const parts = target.split("-");
|
|
242
|
+
return parts.slice(0, -1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* {@link readQualifier} without the lowercase rule — the grammar alone.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} target
|
|
249
|
+
* @param {Set<string>} types
|
|
250
|
+
* @param {Set<string>} [packages]
|
|
251
|
+
* @returns {object|null}
|
|
252
|
+
*/
|
|
253
|
+
function readQualifierCased(target, types, packages) {
|
|
210
254
|
// The slash form is legacy and states neither package nor system, so it is
|
|
211
255
|
// read first and separately. A slash is unconditionally a qualifier —
|
|
212
256
|
// nothing else uses one — which is why an unknown type before it is
|
|
@@ -440,25 +484,24 @@ function foreignHits(index, read) {
|
|
|
440
484
|
const wanted = norm(read.itemDoc ? `doc${read.type}` : read.type);
|
|
441
485
|
const shortcode = norm(read.shortcode);
|
|
442
486
|
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
// — and drift from `readCanonicalKey` the moment the grammar gained a
|
|
449
|
-
// system segment. There is one reader now.
|
|
487
|
+
// **An omitted package means this package** (#336), so a short form
|
|
488
|
+
// addresses nothing foreign and never reaches a dependency's index. A link
|
|
489
|
+
// that resolved into another package only because no local note claimed the
|
|
490
|
+
// address was resolving by accident, and would have retargeted silently the
|
|
491
|
+
// day one did. Reaching another package is the fully qualified form's job.
|
|
450
492
|
//
|
|
451
|
-
// The system
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
493
|
+
// The system likewise comes from where the link is written — `none` in a
|
|
494
|
+
// body — so both segments are known here and this is an exact lookup rather
|
|
495
|
+
// than a filter. That is what makes a cross-package `ambiguous` impossible:
|
|
496
|
+
// one key, one entry.
|
|
497
|
+
if (!read.package) return [];
|
|
498
|
+
const wantedSystem = norm(read.system ?? NO_SYSTEM);
|
|
456
499
|
const hits = [];
|
|
457
500
|
for (const [key, entry] of index.foreign) {
|
|
458
501
|
const parts = readCanonicalKey(key);
|
|
459
502
|
if (!parts) continue;
|
|
460
|
-
if (
|
|
461
|
-
if (
|
|
503
|
+
if (parts.package !== norm(read.package)) continue;
|
|
504
|
+
if (parts.system !== wantedSystem) continue;
|
|
462
505
|
if (parts.type !== wanted || parts.shortcode !== shortcode) continue;
|
|
463
506
|
hits.push(entry);
|
|
464
507
|
}
|
|
@@ -616,7 +659,22 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
|
|
|
616
659
|
});
|
|
617
660
|
return unresolvedLink(text || target, target);
|
|
618
661
|
}
|
|
619
|
-
|
|
662
|
+
// An omitted system defaults from where the link is written (#336),
|
|
663
|
+
// and a body is under no system block, so it is `none`. Under
|
|
664
|
+
// `none` a system-bearing type addresses its *documentation* — a
|
|
665
|
+
// note's `none` address IS its `doc<type>` entry — which is what a
|
|
666
|
+
// prose link almost always means. Stating the system is how prose
|
|
667
|
+
// reaches the Item instead.
|
|
668
|
+
//
|
|
669
|
+
// Only a type whose *own* document carries a system is redirected.
|
|
670
|
+
// A `macro` and the map types have documentation journals too, but
|
|
671
|
+
// their own documents are core ones already at `none`, so
|
|
672
|
+
// `macro-autoattack` names the Macro and `docmacro-autoattack` its
|
|
673
|
+
// journal — two live addresses the redirect would collapse.
|
|
674
|
+
itemDoc =
|
|
675
|
+
qualified.itemDoc ||
|
|
676
|
+
((qualified.system ?? NO_SYSTEM) === NO_SYSTEM &&
|
|
677
|
+
systemOf(qualified.type, KNOWN_DOCUMENT_SUBTYPE_MAPS) !== NO_SYSTEM);
|
|
620
678
|
doc = index.byShortcode.get(`${qualified.type}/${qualified.shortcode}`);
|
|
621
679
|
}
|
|
622
680
|
if (!doc) {
|
package/hm3/actors.mjs
CHANGED
|
@@ -29,10 +29,11 @@
|
|
|
29
29
|
* **What is emitted, and what is deliberately not.** Four rows of the content
|
|
30
30
|
* format's `being` mapping table give HM3 a destination — `data.portrait` →
|
|
31
31
|
* `system.bioImage`, `data.species`, `data.gender`, `data.occupation`, and
|
|
32
|
-
* `data.templatePriority` → `flags.hm3.templatePriority` — and
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* aspirational.
|
|
32
|
+
* `data.templatePriority` → `flags.hm3.templatePriority` — and every one of
|
|
33
|
+
* them is *declared as that source* rather than as the bare key the corpus
|
|
34
|
+
* writes, so the specification's mapping is executable rather than
|
|
35
|
+
* aspirational. Three were declared by #305; `data.portrait` was still read by
|
|
36
|
+
* hand until #332, and so was the one row that did not work. Plus the two anchored prose sections: `{#appearance}` is HM3's `description` and `{#dossier}` its
|
|
36
37
|
* `biography`. Everything else an HM3 actor carries — the thirteen abilities,
|
|
37
38
|
* the sunsign, `move`, `fatigue`, `shockIndex`, a creature's `loadRating` — has
|
|
38
39
|
* no shared source stated anywhere, so it is authored at its own path under
|
|
@@ -49,15 +50,19 @@
|
|
|
49
50
|
*/
|
|
50
51
|
|
|
51
52
|
import { resolveName, resolveImg } from "../engine/helpers.mjs";
|
|
52
|
-
import { buildFromFields, STRING } from "../engine/field-spec.mjs";
|
|
53
|
+
import { buildFromFields, readField, retiredTopLevelKey, STRING } from "../engine/field-spec.mjs";
|
|
53
54
|
import { SystemActorCompiler } from "../engine/actor-compiler.mjs";
|
|
54
55
|
import { renderSection } from "../engine/anchored-sections.mjs";
|
|
55
56
|
import { documentSubtype } from "../engine/document-subtypes.mjs";
|
|
56
57
|
import { HM3_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
|
|
57
58
|
import { templateFlags } from "./template-priority.mjs";
|
|
58
|
-
// The retirement window's
|
|
59
|
-
// two cannot say different things about the same key (#305).
|
|
60
|
-
import {
|
|
59
|
+
// The retirement window's reports, shared with the frontmatter lint so the
|
|
60
|
+
// two cannot say different things about the same key (#305, #332).
|
|
61
|
+
import {
|
|
62
|
+
legacyKeyMessage,
|
|
63
|
+
locateFrontmatterKey,
|
|
64
|
+
retiredTopLevelMessage,
|
|
65
|
+
} from "../engine/retired-fields.mjs";
|
|
61
66
|
// The note-level `hm3:` block: `hm3.system` onto the document's `system`
|
|
62
67
|
// verbatim, and `hm3.img` / `hm3.items` / `hm3.effects` / `hm3.flags`
|
|
63
68
|
// overriding their shared top-level forms for this system alone (#58).
|
|
@@ -94,6 +99,33 @@ const ACTOR_FIELDS = Object.freeze([
|
|
|
94
99
|
},
|
|
95
100
|
]);
|
|
96
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The actor's bio image — the fourth row of the specification's `being` table,
|
|
104
|
+
* and the last one that was still read by hand.
|
|
105
|
+
*
|
|
106
|
+
* Declared for the reason {@link ACTOR_FIELDS} is, and fixed for the reason
|
|
107
|
+
* `data.species` was: read with `blockProperty(fm, block, "portrait")` it saw
|
|
108
|
+
* the block and the note's top level and nothing else, so the `data.portrait`
|
|
109
|
+
* the specification names never reached the document and the `?? defaultImg`
|
|
110
|
+
* beside it dressed the miss up as "this note names no art" (#332).
|
|
111
|
+
*
|
|
112
|
+
* It is **not** in `ACTOR_FIELDS`, because `buildFromFields` has no seam for
|
|
113
|
+
* the subtype default that has to follow it — the `?? defaultImg` is the whole
|
|
114
|
+
* of what distinguishes an unnamed portrait from a deliberately blank one
|
|
115
|
+
* (#218), and it needs a subtype the coercion is not handed.
|
|
116
|
+
*
|
|
117
|
+
* @type {import("../engine/field-spec.mjs").FieldSpec}
|
|
118
|
+
*/
|
|
119
|
+
const BIO_IMAGE_FIELD = Object.freeze({
|
|
120
|
+
name: "data.portrait",
|
|
121
|
+
legacyKey: "portrait",
|
|
122
|
+
to: "bioImage",
|
|
123
|
+
shape: "path",
|
|
124
|
+
read: (raw) => resolveImg(raw),
|
|
125
|
+
default: null,
|
|
126
|
+
describe: "Path to the portrait image.",
|
|
127
|
+
});
|
|
128
|
+
|
|
97
129
|
/**
|
|
98
130
|
* The two `data:` facts HM3 declares on a `character` and not on a `creature`.
|
|
99
131
|
*
|
|
@@ -259,29 +291,44 @@ export class Hm3Actors extends SystemActorCompiler {
|
|
|
259
291
|
locateFrontmatterKey(this.currentNote?.absPath, field.legacyKey),
|
|
260
292
|
);
|
|
261
293
|
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
294
|
+
// The shared level's own retiring position — the top-level key `data:`
|
|
295
|
+
// gathered the field off (#332). Same signal, same severity, separate
|
|
296
|
+
// callback: a note may have moved one position and not the other.
|
|
297
|
+
// Anchored at column 1, because the two spellings coincide — `portrait`
|
|
298
|
+
// names a block key and a top-level one — and a locator that took the
|
|
299
|
+
// first match would point at the wrong line.
|
|
300
|
+
const onRetiredTopLevel = (field) =>
|
|
301
|
+
this.noteWarn(
|
|
302
|
+
retiredTopLevelMessage(field),
|
|
303
|
+
locateFrontmatterKey(
|
|
304
|
+
this.currentNote?.absPath,
|
|
305
|
+
retiredTopLevelKey(field),
|
|
306
|
+
undefined,
|
|
307
|
+
{ topLevel: true },
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
const reports = { block, onLegacyKey, onRetiredTopLevel };
|
|
311
|
+
|
|
312
|
+
// One spelling, as everywhere else: `packFolder` names a folder note
|
|
313
|
+
// by its address. This pass once read only the Foundry id, so an HM3
|
|
314
|
+
// tree could not file an actor by address at all — which its own sweep
|
|
315
|
+
// needs; the id spelling is retired outright (#251, #255, #260).
|
|
316
|
+
const folder = this.folderResolver(blockField(fm, block, "packFolder", null), {
|
|
317
|
+
isAddress: true,
|
|
318
|
+
});
|
|
272
319
|
|
|
273
320
|
const system = {
|
|
274
321
|
// Nullish, not `||` (#218): a note that names no portrait gets the
|
|
275
322
|
// subtype's default, one that writes `""` ships blank on purpose.
|
|
276
|
-
|
|
323
|
+
// Resolved through the declaration so `data.portrait` is reached at
|
|
324
|
+
// all — see {@link BIO_IMAGE_FIELD} (#332).
|
|
325
|
+
bioImage: readField(BIO_IMAGE_FIELD, fm, reports) ?? defaultImg,
|
|
277
326
|
description: renderSection(body || "", "appearance"),
|
|
278
327
|
biography: renderSection(body || "", "dossier"),
|
|
279
|
-
...buildFromFields(ACTOR_FIELDS,
|
|
328
|
+
...buildFromFields(ACTOR_FIELDS, reports)(fm),
|
|
280
329
|
// Declared on `character` alone, so written there alone — see the
|
|
281
330
|
// module note.
|
|
282
|
-
...(subType === "character" ?
|
|
283
|
-
buildFromFields(CHARACTER_FIELDS, { block, onLegacyKey })(fm)
|
|
284
|
-
: {}),
|
|
331
|
+
...(subType === "character" ? buildFromFields(CHARACTER_FIELDS, reports)(fm) : {}),
|
|
285
332
|
};
|
|
286
333
|
|
|
287
334
|
// Whatever the note authors under `hm3.system`, at the DataModel's own
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "20.0.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
"devDependencies": {
|
|
156
156
|
"@changesets/cli": "^3.0.0",
|
|
157
157
|
"@types/node": "^26.2.0",
|
|
158
|
-
"vitest": "^
|
|
158
|
+
"vitest": "^5.0.0"
|
|
159
159
|
},
|
|
160
160
|
"scripts": {
|
|
161
161
|
"test": "vitest run",
|
package/sohl/actors.mjs
CHANGED
|
@@ -63,6 +63,14 @@ import { SOHL_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
|
|
|
63
63
|
// verbatim, and `sohl.img` / `sohl.effects` / `sohl.flags` overriding their
|
|
64
64
|
// shared top-level forms for this system alone (#58).
|
|
65
65
|
import { blockProperty, mergeSystemData } from "../engine/system-block.mjs";
|
|
66
|
+
import { readField, retiredTopLevelKey } from "../engine/field-spec.mjs";
|
|
67
|
+
// The retirement window's reports, shared with the frontmatter lint so the two
|
|
68
|
+
// cannot say different things about the same key (#305, #332).
|
|
69
|
+
import {
|
|
70
|
+
legacyKeyMessage,
|
|
71
|
+
locateFrontmatterKey,
|
|
72
|
+
retiredTopLevelMessage,
|
|
73
|
+
} from "../engine/retired-fields.mjs";
|
|
66
74
|
|
|
67
75
|
/**
|
|
68
76
|
* The system this pass compiles for — the block its notes write.
|
|
@@ -81,6 +89,38 @@ const DEFAULT_IMG = {
|
|
|
81
89
|
being: "systems/sohl/assets/icons/game-icons/delapouite/person.svg",
|
|
82
90
|
};
|
|
83
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The being's sheet portrait — the one row of the content format's actor
|
|
94
|
+
* mapping table that is authored rather than derived.
|
|
95
|
+
*
|
|
96
|
+
* **Declared, because the position is not a spelling anyone can guess.** This
|
|
97
|
+
* was read with `blockProperty(fm, SYSTEM, "portrait")`, which knows the block
|
|
98
|
+
* and the note's top level and nothing else — so `data.portrait`, the position
|
|
99
|
+
* the specification names and `sohl-thalorna` writes on 646 beings, was
|
|
100
|
+
* invisible, and `?? defaultImg` on the next line turned every miss into the
|
|
101
|
+
* generic person icon rather than into a complaint (#332). Going through
|
|
102
|
+
* {@link module:engine/field-spec.readField} is what makes the mapping table
|
|
103
|
+
* executable here as it already is for HM3's `data.species`.
|
|
104
|
+
*
|
|
105
|
+
* `img` is deliberately **not** declared beside it: the mapping table keeps a
|
|
106
|
+
* note's token art at the top level, so `blockProperty` is the whole of its
|
|
107
|
+
* resolution and there is no `data.img` to reach for.
|
|
108
|
+
*
|
|
109
|
+
* @type {import("../engine/field-spec.mjs").FieldSpec}
|
|
110
|
+
*/
|
|
111
|
+
const PORTRAIT_FIELD = Object.freeze({
|
|
112
|
+
name: "data.portrait",
|
|
113
|
+
legacyKey: "portrait",
|
|
114
|
+
to: "portrait",
|
|
115
|
+
shape: "path",
|
|
116
|
+
// The two empties survive, because the caller's `?? defaultImg` is what
|
|
117
|
+
// tells them apart: `null` and an absent key mean "no art named, default
|
|
118
|
+
// me", `""` means "ship blank on purpose" (#218).
|
|
119
|
+
read: (raw) => resolveImg(raw),
|
|
120
|
+
default: null,
|
|
121
|
+
describe: "Path to the portrait image.",
|
|
122
|
+
});
|
|
123
|
+
|
|
84
124
|
/**
|
|
85
125
|
* The default art for an actor subtype.
|
|
86
126
|
*
|
|
@@ -206,21 +246,48 @@ export class Actors extends SystemActorCompiler {
|
|
|
206
246
|
this.errorCount++;
|
|
207
247
|
return;
|
|
208
248
|
}
|
|
209
|
-
const { shortcode, type, ...rest } = entry;
|
|
210
|
-
if (
|
|
211
|
-
this.noteError(
|
|
249
|
+
const { model, shortcode, type, ...rest } = entry;
|
|
250
|
+
if (shortcode !== undefined) {
|
|
251
|
+
this.noteError(
|
|
252
|
+
`${ctx}: sohl.items[${index}] carries a top-level ` +
|
|
253
|
+
`\`shortcode\` — that key is retired. Name the item ` +
|
|
254
|
+
`this entry copies with \`model:\` (an address, e.g. ` +
|
|
255
|
+
`\`${type ?? "skill"}-${shortcode}\`), and use ` +
|
|
256
|
+
`\`system.shortcode\` for this item's own identity.`,
|
|
257
|
+
);
|
|
258
|
+
this.errorCount++;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const read = model === undefined ? null : this.readModel(model, index, ctx);
|
|
262
|
+
if (model !== undefined && !read) return;
|
|
263
|
+
if (read && type !== undefined) {
|
|
264
|
+
this.noteError(
|
|
265
|
+
`${ctx}: sohl.items[${index}] states both \`model\` and ` +
|
|
266
|
+
`\`type\` — the model's address already names the ` +
|
|
267
|
+
`type, so the second is a place to be wrong. Drop \`type\`.`,
|
|
268
|
+
);
|
|
269
|
+
this.errorCount++;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const effectiveType = read ? read.type : type;
|
|
273
|
+
if (!effectiveType) {
|
|
274
|
+
this.noteError(
|
|
275
|
+
`${ctx}: sohl.items[${index}] names no \`model\` and no ` +
|
|
276
|
+
`\`type\` — an entry that copies nothing must state ` +
|
|
277
|
+
`\`name\`, \`type\` and \`system.shortcode\`.`,
|
|
278
|
+
);
|
|
212
279
|
this.errorCount++;
|
|
213
280
|
return;
|
|
214
281
|
}
|
|
215
282
|
const embedded = this.resolveEmbedded(
|
|
216
283
|
itemsMap,
|
|
217
284
|
actorId,
|
|
218
|
-
|
|
219
|
-
shortcode
|
|
285
|
+
effectiveType,
|
|
286
|
+
read ? read.shortcode : null,
|
|
220
287
|
rest,
|
|
221
288
|
`items:${index}`,
|
|
222
289
|
ctx,
|
|
223
|
-
{ fmKey: "items" },
|
|
290
|
+
{ fmKey: "items", modelPackage: read?.package ?? null },
|
|
224
291
|
);
|
|
225
292
|
if (embedded) items.push(embedded);
|
|
226
293
|
});
|
|
@@ -298,6 +365,36 @@ export class Actors extends SystemActorCompiler {
|
|
|
298
365
|
const { value: authoredFolder, isAddress } = folderField(fm);
|
|
299
366
|
const folder = this.folderResolver(authoredFolder, { isAddress });
|
|
300
367
|
|
|
368
|
+
// The two retiring positions a declared field may be read from (#305,
|
|
369
|
+
// #332). **Warnings**, on the pattern every retirement in this package
|
|
370
|
+
// follows: the note compiles to the correct document either way, so
|
|
371
|
+
// reddening a tree over one would refuse before the sweep rather than
|
|
372
|
+
// after it. What they buy is a count — the whole reason #332 was
|
|
373
|
+
// invisible for so long is that nothing said which position a value
|
|
374
|
+
// had come from, and a default is indistinguishable from a miss.
|
|
375
|
+
const portraitReports = {
|
|
376
|
+
block: SYSTEM,
|
|
377
|
+
onLegacyKey: (field) =>
|
|
378
|
+
this.noteWarn(
|
|
379
|
+
legacyKeyMessage(SYSTEM, field),
|
|
380
|
+
locateFrontmatterKey(this.currentNote?.absPath, field.legacyKey),
|
|
381
|
+
),
|
|
382
|
+
// Anchored at column 1: the two positions share a spelling here —
|
|
383
|
+
// `sohl.portrait` and `portrait` — so a locator that took the first
|
|
384
|
+
// match would point at the block key while the message named the
|
|
385
|
+
// top-level one.
|
|
386
|
+
onRetiredTopLevel: (field) =>
|
|
387
|
+
this.noteWarn(
|
|
388
|
+
retiredTopLevelMessage(field),
|
|
389
|
+
locateFrontmatterKey(
|
|
390
|
+
this.currentNote?.absPath,
|
|
391
|
+
retiredTopLevelKey(field),
|
|
392
|
+
undefined,
|
|
393
|
+
{ topLevel: true },
|
|
394
|
+
),
|
|
395
|
+
),
|
|
396
|
+
};
|
|
397
|
+
|
|
301
398
|
const system = {
|
|
302
399
|
// The frontmatter shortcode is the actor's stable `(type, shortcode)`
|
|
303
400
|
// key — and, for a being that is an archetype, its archetype
|
|
@@ -311,7 +408,9 @@ export class Actors extends SystemActorCompiler {
|
|
|
311
408
|
templatePriority: systemTemplatePriority(fm, ctx),
|
|
312
409
|
// Nullish, not `||` (#218): a note that names no portrait gets the
|
|
313
410
|
// subtype's default, one that writes `""` ships blank on purpose.
|
|
314
|
-
|
|
411
|
+
// Resolved through the declaration so `data.portrait` is reached at
|
|
412
|
+
// all — see {@link PORTRAIT_FIELD} (#332).
|
|
413
|
+
portrait: readField(PORTRAIT_FIELD, fm, portraitReports) ?? defaultImg,
|
|
315
414
|
appearance: renderSection(body || "", "appearance"),
|
|
316
415
|
dossier: renderSection(body || "", "dossier"),
|
|
317
416
|
};
|