@heroiclands/package-build 19.0.0 → 20.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +624 -0
- package/CONTENT.md +79 -8
- package/bin/content-build.mjs +7 -1
- package/content-config.mjs +10 -1
- package/docs/content-format.md +394 -72
- package/engine/actor-compiler.mjs +197 -7
- package/engine/address-charset.mjs +23 -5
- package/engine/base-compiler.mjs +63 -2
- package/engine/bundles.mjs +9 -0
- package/engine/content-address.mjs +92 -1
- package/engine/content-format.mjs +102 -0
- package/engine/content-index.mjs +11 -8
- package/engine/content-links.mjs +37 -21
- package/engine/field-reference.mjs +57 -5
- package/engine/field-spec.mjs +214 -7
- package/engine/folder-notes.mjs +24 -1
- package/engine/foreign-catalog.mjs +4 -1
- package/engine/foundry-entries.mjs +14 -0
- package/engine/frontmatter-lint.mjs +186 -27
- package/engine/frontmatter.mjs +11 -11
- package/engine/generate.mjs +67 -10
- package/engine/helpers.mjs +86 -9
- package/engine/index.mjs +3 -0
- package/engine/item-compiler.mjs +37 -0
- package/engine/journals.mjs +21 -4
- package/engine/macros.mjs +8 -0
- package/engine/map-notes.mjs +7 -7
- package/engine/note-ids.mjs +25 -1
- package/engine/note-vocabulary.mjs +76 -9
- package/engine/retired-fields.mjs +57 -16
- package/engine/runtime-only-fields.mjs +204 -0
- package/engine/scenes.mjs +12 -19
- package/engine/schema-check.mjs +23 -1
- package/engine/site-index.mjs +17 -0
- package/engine/subtype-registry.mjs +30 -0
- package/engine/system-block.mjs +81 -3
- package/engine/web-wikilinks.mjs +33 -27
- package/engine/wikilink-syntax.mjs +7 -0
- package/engine/wikilinks.mjs +74 -16
- package/hm3/actors.mjs +63 -13
- package/package.json +2 -2
- package/sohl/actors.mjs +106 -7
- package/sohl/item-fields.mjs +203 -0
- package/sohl/note-schemas.mjs +6 -3
- package/types/engine/actor-compiler.d.mts +83 -3
- package/types/engine/address-charset.d.mts +22 -4
- package/types/engine/base-compiler.d.mts +54 -3
- package/types/engine/content-address.d.mts +64 -0
- package/types/engine/content-format.d.mts +9 -0
- package/types/engine/field-spec.d.mts +271 -3
- package/types/engine/folder-notes.d.mts +20 -0
- package/types/engine/foundry-entries.d.mts +6 -0
- package/types/engine/frontmatter-lint.d.mts +18 -2
- package/types/engine/frontmatter.d.mts +11 -11
- package/types/engine/generate.d.mts +27 -0
- package/types/engine/helpers.d.mts +37 -9
- package/types/engine/index.d.mts +1 -0
- package/types/engine/map-notes.d.mts +2 -2
- package/types/engine/note-ids.d.mts +14 -0
- package/types/engine/retired-fields.d.mts +29 -13
- package/types/engine/runtime-only-fields.d.mts +102 -0
- package/types/engine/schema-check.d.mts +10 -1
- package/types/engine/subtype-registry.d.mts +21 -0
- package/types/engine/system-block.d.mts +28 -2
- package/types/sohl/actors.d.mts +3 -3
package/engine/schema-check.mjs
CHANGED
|
@@ -68,6 +68,9 @@ import path from "node:path";
|
|
|
68
68
|
import { cachedSchemaPath, SCHEMA_ARTIFACT_FILE } from "./foreign-catalog.mjs";
|
|
69
69
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
70
70
|
import { systemData, systemDataPaths, undeclaredPaths } from "./system-block.mjs";
|
|
71
|
+
// A field the document writes for itself in play: declared by the schema,
|
|
72
|
+
// emitted by no builder, and authored by no note (#330).
|
|
73
|
+
import { runtimeOnlyFields } from "./field-spec.mjs";
|
|
71
74
|
|
|
72
75
|
/**
|
|
73
76
|
* The artifact version this module reads.
|
|
@@ -133,12 +136,21 @@ export function declaredFields(artifact, documentType, subtype) {
|
|
|
133
136
|
* the path beneath it separately, so a comparison that knew only the leaf would
|
|
134
137
|
* report the container as unemitted and the leaf as undeclared.
|
|
135
138
|
*
|
|
136
|
-
*
|
|
139
|
+
* **A runtime-only field is not in it** (#330). It declares a `to` in order to
|
|
140
|
+
* *claim* the path — so the verbatim passthrough leaves it alone and the
|
|
141
|
+
* refusal has something to name — and `buildFromFields` deliberately skips it,
|
|
142
|
+
* because the document writes that field in play. Counting it here would make
|
|
143
|
+
* the check assert the builder writes a key it never writes; the *unemitted*
|
|
144
|
+
* direction handles it instead, in {@link compareFields}.
|
|
145
|
+
*
|
|
146
|
+
* @param {readonly {to: string, runtimeOnly?: string}[]} fields - A type's
|
|
147
|
+
* field declaration.
|
|
137
148
|
* @returns {Set<string>} The paths, parents included.
|
|
138
149
|
*/
|
|
139
150
|
export function emittedFields(fields) {
|
|
140
151
|
const out = new Set();
|
|
141
152
|
for (const field of fields ?? []) {
|
|
153
|
+
if (field?.runtimeOnly) continue;
|
|
142
154
|
if (typeof field?.to !== "string" || !field.to) continue;
|
|
143
155
|
const parts = field.to.split(".");
|
|
144
156
|
for (let i = 1; i <= parts.length; i++) {
|
|
@@ -221,6 +233,15 @@ export function compareFields({
|
|
|
221
233
|
}
|
|
222
234
|
|
|
223
235
|
const emitted = emittedFields(fields);
|
|
236
|
+
// Paths the declaration says the *document* writes in play (#330). They
|
|
237
|
+
// are neither emitted nor a defect, so they answer the unemitted
|
|
238
|
+
// question below rather than appearing in it: "every compiled document
|
|
239
|
+
// will carry the field's initial value" is exactly what a runtime-only
|
|
240
|
+
// field is for, and reporting it would leave a permanent warning that
|
|
241
|
+
// the correct declaration cannot clear.
|
|
242
|
+
const runtimeOnly = new Set(
|
|
243
|
+
runtimeOnlyFields(/** @type {never} */ (fields)).map((field) => field.to),
|
|
244
|
+
);
|
|
224
245
|
for (const path of emitted) {
|
|
225
246
|
if (declared.all.has(path)) continue;
|
|
226
247
|
undeclared.push({
|
|
@@ -238,6 +259,7 @@ export function compareFields({
|
|
|
238
259
|
// on a type that populates them correctly — two findings, both
|
|
239
260
|
// false, on the first real schema this was run against.
|
|
240
261
|
if (coveredByAncestor(path, emitted)) continue;
|
|
262
|
+
if (runtimeOnly.has(path)) continue;
|
|
241
263
|
unemitted.push({
|
|
242
264
|
type,
|
|
243
265
|
subtype,
|
package/engine/site-index.mjs
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
import path from "node:path";
|
|
53
53
|
|
|
54
54
|
import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
|
|
55
|
+
import { NO_SYSTEM } from "./systems.mjs";
|
|
55
56
|
import { systemOf } from "./document-subtypes.mjs";
|
|
56
57
|
import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
|
|
57
58
|
import { hasDocEntry } from "./item-docs.mjs";
|
|
@@ -266,6 +267,17 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
|
|
|
266
267
|
if (hasDocEntry(type)) {
|
|
267
268
|
contentTypes.add(`doc${type}`);
|
|
268
269
|
index.set(`doc${type}/${shortcode}`.toLowerCase(), value);
|
|
270
|
+
// The canonical documentation address too, so the page answers
|
|
271
|
+
// to the address a bare prose link expands to (#336): body
|
|
272
|
+
// prose is under no system block, so it defaults to `none`, and
|
|
273
|
+
// a system-bearing type's `none` address is its `doc<type>`
|
|
274
|
+
// one. In Foundry that names a second document; here it names
|
|
275
|
+
// this same page, which is what makes one authored link correct
|
|
276
|
+
// in both builds.
|
|
277
|
+
index.set(
|
|
278
|
+
canonicalKey(e.pkg ?? ownPackage, NO_SYSTEM, `doc${type}`, shortcode),
|
|
279
|
+
value,
|
|
280
|
+
);
|
|
269
281
|
}
|
|
270
282
|
}
|
|
271
283
|
}
|
|
@@ -316,6 +328,11 @@ export function wikiContext(built, { src, file, type = null, errors, foreignInde
|
|
|
316
328
|
sections: built.sections,
|
|
317
329
|
contentTypes: built.contentTypes,
|
|
318
330
|
packages: built.packages,
|
|
331
|
+
// The package a link written on this page defaults to when it names
|
|
332
|
+
// none (#336). Taken from the resolved configuration, the same source
|
|
333
|
+
// the index's own addresses are built from, so a bare link cannot
|
|
334
|
+
// resolve against a package the index never keyed.
|
|
335
|
+
contentPackage: contentPackage(),
|
|
319
336
|
type,
|
|
320
337
|
errors,
|
|
321
338
|
src,
|
|
@@ -64,6 +64,36 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
|
|
|
64
64
|
HM3_DOCUMENT_SUBTYPES,
|
|
65
65
|
]);
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Every note type any shipped map compiles into an **Actor**.
|
|
69
|
+
*
|
|
70
|
+
* Derived from the maps rather than written out, so a system that adds an actor
|
|
71
|
+
* type is covered without a second list to keep in step — the same reason
|
|
72
|
+
* {@link KNOWN_DOCUMENT_SUBTYPE_MAPS} exists rather than a hand-kept table.
|
|
73
|
+
*
|
|
74
|
+
* It exists because an actor note publishes documentation like every other
|
|
75
|
+
* system-bearing note (#337). `docEntryTypes` was `itemTypes` plus `macro` and
|
|
76
|
+
* the map types, which left a being as the one system-bearing note with no
|
|
77
|
+
* `none` address — nothing a prose link could land on, since its only address
|
|
78
|
+
* named the Actor. Composing that set needs to know which types are actors, and
|
|
79
|
+
* this is where the maps that know already live.
|
|
80
|
+
*
|
|
81
|
+
* **Not an item type.** This widens what carries *documentation*; it must never
|
|
82
|
+
* widen what the items pass compiles, or a being note would be compiled into an
|
|
83
|
+
* Item beside its Actor.
|
|
84
|
+
*
|
|
85
|
+
* @type {ReadonlySet<string>}
|
|
86
|
+
*/
|
|
87
|
+
export const ACTOR_TYPES = Object.freeze(
|
|
88
|
+
new Set(
|
|
89
|
+
KNOWN_DOCUMENT_SUBTYPE_MAPS.flatMap((map) =>
|
|
90
|
+
Object.entries(map.types)
|
|
91
|
+
.filter(([, row]) => row?.document === "Actor")
|
|
92
|
+
.map(([noteType]) => noteType),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
|
|
67
97
|
/**
|
|
68
98
|
* The map one system ships, by its id.
|
|
69
99
|
*
|
package/engine/system-block.mjs
CHANGED
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
* kept until #126 moves it;
|
|
51
51
|
* 3. the shared top-level property the field **declares** as its source, which
|
|
52
52
|
* may be a dotted path (`data.portrait`) rather than a sibling key;
|
|
53
|
+
* 3b. for a `data.` source, the bare top-level key that container gathered it
|
|
54
|
+
* off — the retiring *shared* position, derived rather than declared;
|
|
53
55
|
* 4. the field's own default.
|
|
54
56
|
*
|
|
55
57
|
* ## Steps 2 and 3 are two declarations, because they are two positions
|
|
@@ -75,6 +77,29 @@
|
|
|
75
77
|
* that declares one is mid-sweep by construction, which is what
|
|
76
78
|
* {@link module:engine/field-spec.readsLegacyKey} reports on.
|
|
77
79
|
*
|
|
80
|
+
* ## Step 3 has a retiring position too, and it is derived (#332)
|
|
81
|
+
*
|
|
82
|
+
* `legacyKey` retires the *in-block* position, and for a while that looked like
|
|
83
|
+
* the whole of what `data:` left behind. It is not. The facts `data:` holds
|
|
84
|
+
* were not invented by it — #128 **gathered** them out of the note's open top
|
|
85
|
+
* level, where `portrait:` sat beside `img:` and `shortcode:` — so a field that
|
|
86
|
+
* declares `data.portrait` has two shared spellings to read, not one, and
|
|
87
|
+
* reading only the current one is the same silent miss `legacyKey` exists to
|
|
88
|
+
* prevent. It is worse here, because the caller's `?? default` cannot tell a
|
|
89
|
+
* value that is absent from one that is merely unreachable: 646 `sohl-thalorna`
|
|
90
|
+
* beings shipped the generic person icon over an authored path, and no tree
|
|
91
|
+
* that still writes the top-level spelling — `sohl`'s own bestiary does —
|
|
92
|
+
* looked any different from one that names no art at all.
|
|
93
|
+
*
|
|
94
|
+
* So step 3b reads it, and unlike `legacyKey` it is **derived**: the retiring
|
|
95
|
+
* spelling of `data.<key>` is `<key>`, mechanically, because that is precisely
|
|
96
|
+
* what the move did. A second declaration would be a second place for the same
|
|
97
|
+
* fact to be stated, and the retirement it describes is one rule rather than a
|
|
98
|
+
* per-field decision. {@link retiredTopLevelKey} is that derivation, and it
|
|
99
|
+
* answers for `data.` sources alone — `protection.blunt` is a path into a
|
|
100
|
+
* container notes have always written at the top level, never a `blunt:` that
|
|
101
|
+
* moved.
|
|
102
|
+
*
|
|
78
103
|
* ## A name that collides across the two vocabularies skips step 3
|
|
79
104
|
*
|
|
80
105
|
* A field's `name` is both its identity and the shared property it draws from,
|
|
@@ -90,8 +115,10 @@
|
|
|
90
115
|
* `"null"` in fifteen documents (#218).
|
|
91
116
|
*
|
|
92
117
|
* So a field may declare `topLevelMeans`: what the top-level key of that name
|
|
93
|
-
* means *instead*. Declaring it removes
|
|
94
|
-
*
|
|
118
|
+
* means *instead*. Declaring it removes the whole shared level — step 3 and the
|
|
119
|
+
* retiring 3b alike, since both read the note's top level and the objection is
|
|
120
|
+
* to that level, not to a spelling — leaving the two positions that describe
|
|
121
|
+
* the document rather than the note. It is deliberately
|
|
95
122
|
* a per-field opt-out rather than a change to the order — step 3 is right
|
|
96
123
|
* wherever the two levels state the same quantity, which is nearly everywhere —
|
|
97
124
|
* and its value is the reason rather than a bare flag, so the collision is
|
|
@@ -356,6 +383,43 @@ export function legacyKeyOf(field) {
|
|
|
356
383
|
return field?.legacyKey ?? field?.name;
|
|
357
384
|
}
|
|
358
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The `data:` container's prefix, as a shared source spells it.
|
|
388
|
+
*
|
|
389
|
+
* @type {string}
|
|
390
|
+
*/
|
|
391
|
+
const DATA_PREFIX = "data.";
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The bare top-level key a `data:`-sourced field is being swept off — step 3b.
|
|
395
|
+
*
|
|
396
|
+
* `data:` (#128) did not invent the facts it holds; it *gathered* them, out of
|
|
397
|
+
* the note's open top level where each was a sibling of `img` and `shortcode`.
|
|
398
|
+
* So the retiring spelling of `data.portrait` is not a second declaration
|
|
399
|
+
* anyone has to write — it is `portrait`, mechanically, and the same holds for
|
|
400
|
+
* every other key that move relocated. Deriving it is what keeps the two
|
|
401
|
+
* spellings of one field from disagreeing the way two declarations would.
|
|
402
|
+
*
|
|
403
|
+
* **Only a `data.` source has one.** `protection.blunt` and `impact.die` are
|
|
404
|
+
* paths into containers a note has always written at the top level; they were
|
|
405
|
+
* never `blunt:` or `die:`, and reading those would invent a position rather
|
|
406
|
+
* than remember one.
|
|
407
|
+
*
|
|
408
|
+
* A field declaring {@link module:engine/field-spec.FieldSpec `topLevelMeans`}
|
|
409
|
+
* has no shared position at all, retiring or otherwise — the resolver checks
|
|
410
|
+
* that before asking.
|
|
411
|
+
*
|
|
412
|
+
* @param {{name?: string}} field - The declaration.
|
|
413
|
+
* @returns {string|undefined} The retiring top-level path, or `undefined` for a
|
|
414
|
+
* field whose shared source never lived there.
|
|
415
|
+
*/
|
|
416
|
+
export function retiredTopLevelKey(field) {
|
|
417
|
+
const name = field?.name;
|
|
418
|
+
if (typeof name !== "string" || !name.startsWith(DATA_PREFIX)) return undefined;
|
|
419
|
+
const rest = name.slice(DATA_PREFIX.length);
|
|
420
|
+
return rest === "" ? undefined : rest;
|
|
421
|
+
}
|
|
422
|
+
|
|
359
423
|
/**
|
|
360
424
|
* Where a declared field's value came from.
|
|
361
425
|
*
|
|
@@ -363,7 +427,7 @@ export function legacyKeyOf(field) {
|
|
|
363
427
|
* can distinguish a value an author wrote from one a default supplied, which
|
|
364
428
|
* the value alone never says.
|
|
365
429
|
*
|
|
366
|
-
* @typedef {"system"|"block"|"shared"|"default"|"value"} FieldSource
|
|
430
|
+
* @typedef {"system"|"block"|"shared"|"topLevel"|"default"|"value"} FieldSource
|
|
367
431
|
*/
|
|
368
432
|
|
|
369
433
|
/**
|
|
@@ -422,6 +486,20 @@ export function resolveFieldValue(field, fm, { block = "sohl" } = {}) {
|
|
|
422
486
|
if (field.topLevelMeans === undefined) {
|
|
423
487
|
const shared = getFrontmatter(fm, field.name, undefined);
|
|
424
488
|
if (shared !== undefined) return { value: shared, from: "shared" };
|
|
489
|
+
|
|
490
|
+
// 3b. The bare top-level key the `data:` source was gathered off — the
|
|
491
|
+
// retiring *shared* position, exactly as `legacyKey` is the
|
|
492
|
+
// retiring *in-block* one (#332). Without it a field declaring
|
|
493
|
+
// `data.portrait` cannot see the `portrait:` every tree still
|
|
494
|
+
// writes, and the miss arrives at the caller's `?? default` as an
|
|
495
|
+
// ordinary absence: 646 `sohl-thalorna` beings compiled the generic
|
|
496
|
+
// person icon over an authored path, deterministically and with
|
|
497
|
+
// nothing said.
|
|
498
|
+
const retiring = retiredTopLevelKey(field);
|
|
499
|
+
if (retiring !== undefined) {
|
|
500
|
+
const legacy = getFrontmatter(fm, retiring, undefined);
|
|
501
|
+
if (legacy !== undefined) return { value: legacy, from: "topLevel" };
|
|
502
|
+
}
|
|
425
503
|
}
|
|
426
504
|
|
|
427
505
|
// 4. The field's own default.
|
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,6 +291,24 @@ export class Hm3Actors extends SystemActorCompiler {
|
|
|
259
291
|
locateFrontmatterKey(this.currentNote?.absPath, field.legacyKey),
|
|
260
292
|
);
|
|
261
293
|
|
|
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
|
+
|
|
262
312
|
// One spelling, as everywhere else: `packFolder` names a folder note
|
|
263
313
|
// by its address. This pass once read only the Foundry id, so an HM3
|
|
264
314
|
// tree could not file an actor by address at all — which its own sweep
|
|
@@ -270,15 +320,15 @@ export class Hm3Actors extends SystemActorCompiler {
|
|
|
270
320
|
const system = {
|
|
271
321
|
// Nullish, not `||` (#218): a note that names no portrait gets the
|
|
272
322
|
// subtype's default, one that writes `""` ships blank on purpose.
|
|
273
|
-
|
|
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,
|
|
274
326
|
description: renderSection(body || "", "appearance"),
|
|
275
327
|
biography: renderSection(body || "", "dossier"),
|
|
276
|
-
...buildFromFields(ACTOR_FIELDS,
|
|
328
|
+
...buildFromFields(ACTOR_FIELDS, reports)(fm),
|
|
277
329
|
// Declared on `character` alone, so written there alone — see the
|
|
278
330
|
// module note.
|
|
279
|
-
...(subType === "character" ?
|
|
280
|
-
buildFromFields(CHARACTER_FIELDS, { block, onLegacyKey })(fm)
|
|
281
|
-
: {}),
|
|
331
|
+
...(subType === "character" ? buildFromFields(CHARACTER_FIELDS, reports)(fm) : {}),
|
|
282
332
|
};
|
|
283
333
|
|
|
284
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",
|