@heroiclands/package-build 22.9.2 → 22.11.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 +56 -0
- package/CONTENT.md +54 -0
- package/bin/content-build.mjs +3 -1
- package/docs/api.md +11 -6
- package/docs/commands.md +1 -1
- package/docs/content-format.md +411 -201
- package/engine/actor-compiler.mjs +20 -7
- package/engine/address-fields.mjs +426 -0
- package/engine/address-values.mjs +80 -0
- package/engine/address.mjs +769 -0
- package/engine/art-fields.mjs +112 -97
- package/engine/art-slots.mjs +81 -0
- package/engine/base-compiler.mjs +61 -7
- package/engine/bundle-notes.mjs +5 -2
- package/engine/bundles.mjs +17 -10
- package/engine/content-address.mjs +23 -186
- package/engine/content-embeds.mjs +18 -4
- package/engine/content-format-check.mjs +50 -22
- package/engine/content-index.mjs +85 -43
- package/engine/content-links.mjs +105 -104
- package/engine/content-lint.mjs +11 -1
- package/engine/content-tables.mjs +9 -1
- package/engine/field-spec.mjs +21 -4
- package/engine/folder-notes.mjs +44 -15
- package/engine/foundry-entries.mjs +3 -0
- package/engine/frontmatter-lint.mjs +150 -47
- package/engine/frontmatter.mjs +55 -10
- package/engine/generate.mjs +7 -1
- package/engine/helpers.mjs +37 -9
- package/engine/holdings.mjs +41 -12
- package/engine/index-records.mjs +2 -0
- package/engine/infobox-render.mjs +2 -1
- package/engine/infobox.mjs +26 -6
- package/engine/item-compiler.mjs +5 -0
- package/engine/item-docs.mjs +7 -44
- package/engine/map-dot.mjs +2 -0
- package/engine/map-notes.mjs +7 -2
- package/engine/map-places.mjs +42 -10
- package/engine/metadata-index.mjs +60 -5
- package/engine/note-addresses.mjs +325 -0
- package/engine/note-vocabulary.mjs +140 -28
- package/engine/pdf-build.mjs +1 -1
- package/engine/pdf-toc.mjs +17 -2
- package/engine/place-relations.mjs +160 -50
- package/engine/populations.mjs +17 -9
- package/engine/reference-targets.mjs +54 -0
- package/engine/scenes.mjs +5 -2
- package/engine/shortcode-references.mjs +68 -0
- package/engine/site-build.mjs +39 -15
- package/engine/site-index.mjs +57 -75
- package/engine/sql-tables.mjs +43 -9
- package/engine/system-block.mjs +21 -0
- package/engine/system-vocabulary.mjs +46 -0
- package/engine/web-wikilinks.mjs +11 -9
- package/engine/wikilink-syntax.mjs +21 -0
- package/engine/wikilinks.mjs +92 -306
- package/hm3/actors.mjs +8 -24
- package/hm3/item-fields.mjs +15 -5
- package/package.json +1 -1
- package/sohl/infobox.mjs +111 -17
- package/sohl/item-fields.mjs +131 -41
- package/types/engine/address-fields.d.mts +95 -0
- package/types/engine/address-values.d.mts +34 -0
- package/types/engine/address.d.mts +452 -0
- package/types/engine/art-fields.d.mts +59 -53
- package/types/engine/art-slots.d.mts +40 -0
- package/types/engine/base-compiler.d.mts +24 -2
- package/types/engine/bundle-notes.d.mts +2 -2
- package/types/engine/content-address.d.mts +3 -139
- package/types/engine/content-embeds.d.mts +1 -1
- package/types/engine/content-format-check.d.mts +25 -0
- package/types/engine/content-index.d.mts +2 -2
- package/types/engine/content-links.d.mts +6 -5
- package/types/engine/field-spec.d.mts +46 -9
- package/types/engine/folder-notes.d.mts +11 -10
- package/types/engine/frontmatter-lint.d.mts +7 -97
- package/types/engine/frontmatter.d.mts +16 -1
- package/types/engine/helpers.d.mts +2 -2
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/item-docs.d.mts +1 -35
- package/types/engine/map-notes.d.mts +2 -2
- package/types/engine/note-addresses.d.mts +41 -0
- package/types/engine/note-vocabulary.d.mts +20 -3
- package/types/engine/place-relations.d.mts +12 -1
- package/types/engine/populations.d.mts +17 -2
- package/types/engine/reference-targets.d.mts +9 -0
- package/types/engine/shortcode-references.d.mts +14 -0
- package/types/engine/site-build.d.mts +3 -3
- package/types/engine/site-index.d.mts +9 -30
- package/types/engine/sql-tables.d.mts +2 -2
- package/types/engine/system-block.d.mts +17 -0
- package/types/engine/system-vocabulary.d.mts +16 -0
- package/types/engine/wikilink-syntax.d.mts +7 -0
- package/types/engine/wikilinks.d.mts +6 -109
- package/types/hm3/item-fields.d.mts +5 -0
- package/types/sohl/infobox.d.mts +6 -5
- package/types/sohl/item-fields.d.mts +8 -0
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
* @module
|
|
55
55
|
*/
|
|
56
56
|
|
|
57
|
+
import { isAddressTuple } from "./address.mjs";
|
|
58
|
+
import { AddressEntries } from "./address-values.mjs";
|
|
57
59
|
import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
|
|
58
60
|
import {
|
|
59
61
|
legacyKeyOf,
|
|
@@ -66,6 +68,8 @@ import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.
|
|
|
66
68
|
import { pathnameProblem } from "./pathnames.mjs";
|
|
67
69
|
import { checkHomepageAddressFields } from "./homepage.mjs";
|
|
68
70
|
import { RETIRED_TYPES, RENAMED_TYPES, currentType, renamedTypeMessage } from "./ids.mjs";
|
|
71
|
+
import { parseAddress, acceptsType } from "./address.mjs";
|
|
72
|
+
import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
|
|
69
73
|
import { isAddressSegment } from "./address-charset.mjs";
|
|
70
74
|
// The one place the "every pack not named" key is spelled. Imported rather
|
|
71
75
|
// than repeated, because a linter holding its own copy of what the compiler
|
|
@@ -196,19 +200,7 @@ export const DEFAULT_SYSTEM_BLOCKS = Object.freeze({
|
|
|
196
200
|
* @param {object} [config] - A resolved configuration from `defineConfig`.
|
|
197
201
|
* @returns {string[]} The system ids, deduplicated, in declared order.
|
|
198
202
|
*/
|
|
199
|
-
export
|
|
200
|
-
const out = [];
|
|
201
|
-
for (const system of Object.keys(config?.systems ?? {})) {
|
|
202
|
-
if (!out.includes(system)) out.push(system);
|
|
203
|
-
}
|
|
204
|
-
for (const pack of config?.packs ?? []) {
|
|
205
|
-
const system = pack?.system;
|
|
206
|
-
if (typeof system === "string" && system && !out.includes(system)) out.push(system);
|
|
207
|
-
}
|
|
208
|
-
if (out.length) return out;
|
|
209
|
-
const packageWide = config?.stats?.systemId;
|
|
210
|
-
return typeof packageWide === "string" && packageWide ? [packageWide] : [];
|
|
211
|
-
}
|
|
203
|
+
export { declaredSystems } from "./system-vocabulary.mjs";
|
|
212
204
|
|
|
213
205
|
/**
|
|
214
206
|
* The system blocks a configuration says its tree carries, and what each
|
|
@@ -261,19 +253,7 @@ export function declaredSystems(config) {
|
|
|
261
253
|
* @returns {Readonly<Record<string, SystemBlockSpec>>} The blocks to check, in
|
|
262
254
|
* declared order. A system nothing states the vocabulary of is absent.
|
|
263
255
|
*/
|
|
264
|
-
export
|
|
265
|
-
const byName = config?.itemFieldsBySystem ?? {};
|
|
266
|
-
/** @type {Record<string, SystemBlockSpec>} */
|
|
267
|
-
const blocks = {};
|
|
268
|
-
for (const system of declaredSystems(config)) {
|
|
269
|
-
/** @type {SystemBlockSpec} */
|
|
270
|
-
const spec = {};
|
|
271
|
-
if (system === schemaSystem) spec.fieldVocabulary = true;
|
|
272
|
-
if (byName[system]) spec.fields = byName[system];
|
|
273
|
-
if (Object.keys(spec).length) blocks[system] = Object.freeze(spec);
|
|
274
|
-
}
|
|
275
|
-
return Object.freeze(blocks);
|
|
276
|
-
}
|
|
256
|
+
export { systemBlocksFor } from "./system-vocabulary.mjs";
|
|
277
257
|
|
|
278
258
|
/**
|
|
279
259
|
* Edit distance, capped — enough to answer "did you mean".
|
|
@@ -337,10 +317,15 @@ function nearest(key, candidates) {
|
|
|
337
317
|
*
|
|
338
318
|
* @param {unknown} value - The authored value.
|
|
339
319
|
* @param {string} kind - The declared kind.
|
|
320
|
+
* @param {object} [context] - Complete Address defaults and known type vocabulary.
|
|
340
321
|
* @returns {boolean} Whether it is acceptable.
|
|
341
322
|
*/
|
|
342
|
-
export function matchesKind(value, kind) {
|
|
323
|
+
export function matchesKind(value, kind, context = {}) {
|
|
343
324
|
switch (kind) {
|
|
325
|
+
case "address":
|
|
326
|
+
return !parseAddress(value, context, { declared: true }).reason;
|
|
327
|
+
case "shortcode":
|
|
328
|
+
return isAddressSegment(value);
|
|
344
329
|
case "number":
|
|
345
330
|
return typeof value === "number" ?
|
|
346
331
|
Number.isFinite(value)
|
|
@@ -382,6 +367,7 @@ export function matchesKind(value, kind) {
|
|
|
382
367
|
* @returns {Record<string, unknown>|null} Its entries, or `null` for a scalar.
|
|
383
368
|
*/
|
|
384
369
|
function mapEntries(value) {
|
|
370
|
+
if (isAddressTuple(value)) return null;
|
|
385
371
|
if (Array.isArray(value)) return value.length === 0 ? {} : null;
|
|
386
372
|
if (typeof value !== "object" || value === null) return null;
|
|
387
373
|
return /** @type {Record<string, unknown>} */ (value);
|
|
@@ -427,6 +413,7 @@ function dataBlock(fm) {
|
|
|
427
413
|
* @param {object} opts
|
|
428
414
|
* @param {string} opts.type - The note's type, for the message.
|
|
429
415
|
* @param {readonly object[]} opts.fields - The type's `data:` declaration.
|
|
416
|
+
* @param {object} [opts.addressContext] - Builder package and Address type vocabulary.
|
|
430
417
|
* @param {readonly string[]} [opts.packs] - The pack names this package
|
|
431
418
|
* declares, against which a `keys: "pack"` map's keys are checked. Absent,
|
|
432
419
|
* no claim is made about them: a caller that cannot see the configuration
|
|
@@ -434,7 +421,7 @@ function dataBlock(fm) {
|
|
|
434
421
|
* was loaded to recognise it would be worse than not checking.
|
|
435
422
|
* @returns {object[]} Findings.
|
|
436
423
|
*/
|
|
437
|
-
function checkDataContainer(note, { type, fields, packs }) {
|
|
424
|
+
function checkDataContainer(note, { type, fields, packs, addressContext }) {
|
|
438
425
|
const findings = [];
|
|
439
426
|
const { present, entries, malformed } = dataBlock(note.fm ?? {});
|
|
440
427
|
if (!present) return findings;
|
|
@@ -489,10 +476,12 @@ function checkDataContainer(note, { type, fields, packs }) {
|
|
|
489
476
|
...positionOfFrontmatterPath(raw, ["data", key], { key: true }),
|
|
490
477
|
severity: "error",
|
|
491
478
|
message:
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
`not
|
|
495
|
-
|
|
479
|
+
type === "affiliation" && key === "commonSkills" ?
|
|
480
|
+
"`data.commonSkills` is not an affiliation field; write skill Addresses at `sohl.system.commonSkills`"
|
|
481
|
+
: `"${key}" is not a \`data:\` property declared by ${type}; ` +
|
|
482
|
+
`the container is closed, so unlike a top-level key it is ` +
|
|
483
|
+
`not passed through to the page` +
|
|
484
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
496
485
|
});
|
|
497
486
|
}
|
|
498
487
|
|
|
@@ -504,7 +493,7 @@ function checkDataContainer(note, { type, fields, packs }) {
|
|
|
504
493
|
value = value && typeof value === "object" ? value[segment] : undefined;
|
|
505
494
|
}
|
|
506
495
|
if (value === undefined || value === null) continue;
|
|
507
|
-
if (!matchesKind(value, field.kind)) {
|
|
496
|
+
if (!matchesKind(value, field.kind, { ...addressContext, type: field.ref })) {
|
|
508
497
|
findings.push({
|
|
509
498
|
file: note.file,
|
|
510
499
|
...positionOfFrontmatterPath(raw, ["data", ...segments]),
|
|
@@ -515,6 +504,7 @@ function checkDataContainer(note, { type, fields, packs }) {
|
|
|
515
504
|
});
|
|
516
505
|
continue;
|
|
517
506
|
}
|
|
507
|
+
findings.push(...checkDataReferences(note, field, value, segments, addressContext));
|
|
518
508
|
// A `scalar-or-map` written in its map form is checked entry by entry,
|
|
519
509
|
// because that is the correction an author has to make: one key's
|
|
520
510
|
// value, not the whole map. Quoting the map back would name every
|
|
@@ -530,6 +520,76 @@ function checkDataContainer(note, { type, fields, packs }) {
|
|
|
530
520
|
return findings;
|
|
531
521
|
}
|
|
532
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Validate typed entries without resolving their targets.
|
|
525
|
+
* @param {object} note - The authored note.
|
|
526
|
+
* @param {object} field - Its data field declaration.
|
|
527
|
+
* @param {unknown} value - The field value.
|
|
528
|
+
* @param {string[]} segments - The field's path under `data`.
|
|
529
|
+
* @param {object} context - The builder's Address context.
|
|
530
|
+
* @returns {object[]} Located findings.
|
|
531
|
+
*/
|
|
532
|
+
function checkDataReferences(note, field, value, segments, context) {
|
|
533
|
+
const checks = [];
|
|
534
|
+
if (field.kind === "address") checks.push({ value, path: [], kind: "address" });
|
|
535
|
+
if (field.entryKind) {
|
|
536
|
+
if (Array.isArray(value)) {
|
|
537
|
+
value.forEach((entry, i) =>
|
|
538
|
+
checks.push({ value: entry, path: [i], kind: field.entryKind }),
|
|
539
|
+
);
|
|
540
|
+
} else if (value && typeof value === "object" && !isAddressTuple(value)) {
|
|
541
|
+
Object.entries(value).forEach(([key, entry]) => {
|
|
542
|
+
if (entry != null)
|
|
543
|
+
checks.push({ value: entry, path: [key], kind: field.entryKind, atKey: true });
|
|
544
|
+
});
|
|
545
|
+
} else checks.push({ value, path: [], kind: field.entryKind });
|
|
546
|
+
}
|
|
547
|
+
if (field.keyKind) {
|
|
548
|
+
if (value instanceof AddressEntries) {
|
|
549
|
+
for (const entry of value.entries)
|
|
550
|
+
checks.push({
|
|
551
|
+
value: entry.target,
|
|
552
|
+
path: [entry.sourceKey],
|
|
553
|
+
kind: field.keyKind,
|
|
554
|
+
key: true,
|
|
555
|
+
});
|
|
556
|
+
} else
|
|
557
|
+
Object.keys(value).forEach((key) =>
|
|
558
|
+
checks.push({ value: key, path: [key], kind: field.keyKind, key: true }),
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
const findings = [];
|
|
562
|
+
const defaults = { ...context, type: field.ref };
|
|
563
|
+
const accepted = field.accepts;
|
|
564
|
+
for (const check of checks) {
|
|
565
|
+
if (
|
|
566
|
+
check.key &&
|
|
567
|
+
field.keySelector === "subType" &&
|
|
568
|
+
typeof check.value === "string" &&
|
|
569
|
+
check.value.startsWith("subType:") &&
|
|
570
|
+
isAddressSegment(check.value.slice("subType:".length))
|
|
571
|
+
)
|
|
572
|
+
continue;
|
|
573
|
+
let reason;
|
|
574
|
+
if (!matchesKind(check.value, check.kind, defaults))
|
|
575
|
+
reason = `should be ${check.kind === "address" ? "an Address" : "a Shortcode"}`;
|
|
576
|
+
else if (check.kind === "address" && accepted) {
|
|
577
|
+
const tuple = parseAddress(check.value, defaults, { declared: true });
|
|
578
|
+
if (!acceptsType(tuple, accepted))
|
|
579
|
+
reason = `accepts ${accepted.join(" or ")}, not ${tuple.type}`;
|
|
580
|
+
}
|
|
581
|
+
if (!reason) continue;
|
|
582
|
+
const path = ["data", ...segments, ...check.path];
|
|
583
|
+
findings.push({
|
|
584
|
+
file: note.file,
|
|
585
|
+
...positionOfFrontmatterPath(note.raw ?? "", path, { key: check.key || check.atKey }),
|
|
586
|
+
severity: "error",
|
|
587
|
+
message: `\`${path.join(".")}\` ${reason}, but reads ${JSON.stringify(check.value)}`,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
return findings;
|
|
591
|
+
}
|
|
592
|
+
|
|
533
593
|
/**
|
|
534
594
|
* Check one `scalar-or-map` field written in its map form, entry by entry.
|
|
535
595
|
*
|
|
@@ -578,7 +638,7 @@ function checkKeyedMap(note, { field, segments, entries, raw, packs }) {
|
|
|
578
638
|
// An explicit `~` under a key is a statement, not an omission: it says
|
|
579
639
|
// "at the root there", which is different from saying nothing.
|
|
580
640
|
if (value === undefined || value === null) continue;
|
|
581
|
-
if (matchesKind(value, "string")) continue;
|
|
641
|
+
if (field.entryKind || matchesKind(value, "string")) continue;
|
|
582
642
|
findings.push({
|
|
583
643
|
file: note.file,
|
|
584
644
|
// On the key, not the value: an entry whose value is itself a map
|
|
@@ -1000,7 +1060,7 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
1000
1060
|
* @param {object} opts
|
|
1001
1061
|
* @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
|
|
1002
1062
|
* @param {object} [opts.index] - The link index, for the reference check, which
|
|
1003
|
-
* runs through its `
|
|
1063
|
+
* runs through its `shortcodeHit`. Its absence skips that check rather than
|
|
1004
1064
|
* reporting every reference as dead.
|
|
1005
1065
|
* @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
|
|
1006
1066
|
* it declares, as `engine/note-vocabulary.mjs` states them. Supplied
|
|
@@ -1019,6 +1079,7 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
1019
1079
|
* for the same reason `schemas` is — a build derives them from its
|
|
1020
1080
|
* configuration through {@link systemBlocksFor}, and this module states no
|
|
1021
1081
|
* system name of its own. See {@link DEFAULT_SYSTEM_BLOCKS} for the fallback.
|
|
1082
|
+
* @param {object} [opts.addressContext] - Builder package and Address type vocabulary.
|
|
1022
1083
|
* @param {readonly string[]} [opts.packs] - The pack names this package
|
|
1023
1084
|
* declares, for a `data:` field whose map is keyed by pack. Supplied by the
|
|
1024
1085
|
* caller like `schemas` and `vocabulary`, and absent it no claim is made
|
|
@@ -1027,10 +1088,29 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
1027
1088
|
*/
|
|
1028
1089
|
export function lintNote(
|
|
1029
1090
|
note,
|
|
1030
|
-
{
|
|
1091
|
+
{
|
|
1092
|
+
schemas,
|
|
1093
|
+
index,
|
|
1094
|
+
vocabulary,
|
|
1095
|
+
packs,
|
|
1096
|
+
emittedArt,
|
|
1097
|
+
addressContext,
|
|
1098
|
+
systems = DEFAULT_SYSTEM_BLOCKS,
|
|
1099
|
+
},
|
|
1031
1100
|
) {
|
|
1032
1101
|
const findings = [];
|
|
1033
1102
|
const fm = note.fm ?? {};
|
|
1103
|
+
const referenceContext = {
|
|
1104
|
+
package: index?.contentPackage,
|
|
1105
|
+
system: "none",
|
|
1106
|
+
...addressContext,
|
|
1107
|
+
types: new Set([
|
|
1108
|
+
...Object.keys(vocabulary ?? {}),
|
|
1109
|
+
...ASSET_TYPE_NAMES,
|
|
1110
|
+
...(index?.types ?? []),
|
|
1111
|
+
...(addressContext?.types ?? []),
|
|
1112
|
+
]),
|
|
1113
|
+
};
|
|
1034
1114
|
const type = String(fm.type ?? "");
|
|
1035
1115
|
const raw = () => note.raw ?? "";
|
|
1036
1116
|
const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
|
|
@@ -1197,7 +1277,7 @@ export function lintNote(
|
|
|
1197
1277
|
inData,
|
|
1198
1278
|
blockCollides: blockCollisions.has(key),
|
|
1199
1279
|
});
|
|
1200
|
-
if (typeof authored !== "string") continue;
|
|
1280
|
+
if (typeof authored !== "string" && !isAddressTuple(authored)) continue;
|
|
1201
1281
|
findings.push({
|
|
1202
1282
|
file: note.file,
|
|
1203
1283
|
...at(key),
|
|
@@ -1225,7 +1305,7 @@ export function lintNote(
|
|
|
1225
1305
|
inData,
|
|
1226
1306
|
blockCollides: blockCollisions.has(key),
|
|
1227
1307
|
});
|
|
1228
|
-
if (typeof authored !== "string") continue;
|
|
1308
|
+
if (typeof authored !== "string" && !isAddressTuple(authored)) continue;
|
|
1229
1309
|
const problem = pathnameProblem(authored);
|
|
1230
1310
|
if (!problem) continue;
|
|
1231
1311
|
findings.push({
|
|
@@ -1438,6 +1518,7 @@ export function lintNote(
|
|
|
1438
1518
|
// the container check would have to be told about.
|
|
1439
1519
|
fields,
|
|
1440
1520
|
packs,
|
|
1521
|
+
addressContext: referenceContext,
|
|
1441
1522
|
}),
|
|
1442
1523
|
);
|
|
1443
1524
|
findings.push(...checkSubType(note, { type, entry }));
|
|
@@ -1605,20 +1686,37 @@ export function lintNote(
|
|
|
1605
1686
|
continue;
|
|
1606
1687
|
}
|
|
1607
1688
|
|
|
1608
|
-
// A
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1689
|
+
// A `code:` field's value is a Shortcode, not an Address: one segment,
|
|
1690
|
+
// persisted verbatim and resolved at runtime among the items embedded
|
|
1691
|
+
// on one actor, where packages do not exist. The charset is checked
|
|
1692
|
+
// first, because a value carrying the separator cannot mean what this
|
|
1693
|
+
// position asks for, whatever the field supplies as the type — and the
|
|
1694
|
+
// message says so, rather than reporting a lookup failure for a value
|
|
1695
|
+
// that was never going to resolve. Once it passes, the field's type and
|
|
1696
|
+
// the value form the `(type, shortcode)` pair the resolver takes; it
|
|
1697
|
+
// resolves in any reachable package, because the value comes from
|
|
1698
|
+
// every package the actor draws on.
|
|
1699
|
+
const codeType = field.code;
|
|
1700
|
+
if (codeType && typeof value === "string" && value) {
|
|
1701
|
+
if (!isAddressSegment(value)) {
|
|
1702
|
+
findings.push({
|
|
1703
|
+
file: note.file,
|
|
1704
|
+
...at(head, value),
|
|
1705
|
+
severity: "error",
|
|
1706
|
+
message:
|
|
1707
|
+
`${label} must be a shortcode — the ${codeType}'s ` +
|
|
1708
|
+
`\`shortcode\`, not an address — because this position ` +
|
|
1709
|
+
`resolves it at runtime among one actor's embedded ` +
|
|
1710
|
+
`items, where packages do not exist, but reads ` +
|
|
1711
|
+
`${JSON.stringify(value)}`,
|
|
1712
|
+
});
|
|
1713
|
+
} else if (index && !index.shortcodeHit(codeType, value)) {
|
|
1616
1714
|
findings.push({
|
|
1617
1715
|
file: note.file,
|
|
1618
1716
|
...at(head, value),
|
|
1619
1717
|
severity: "error",
|
|
1620
1718
|
message:
|
|
1621
|
-
`${label} names ${
|
|
1719
|
+
`${label} names ${codeType} ` +
|
|
1622
1720
|
`"${value}", and no note or fetched index declares it`,
|
|
1623
1721
|
});
|
|
1624
1722
|
}
|
|
@@ -1665,6 +1763,11 @@ export function lintFrontmatter(
|
|
|
1665
1763
|
packs,
|
|
1666
1764
|
emittedArt,
|
|
1667
1765
|
index: references ? index : undefined,
|
|
1766
|
+
addressContext: {
|
|
1767
|
+
package: index.contentPackage,
|
|
1768
|
+
system: "none",
|
|
1769
|
+
types: index.types,
|
|
1770
|
+
},
|
|
1668
1771
|
...(systems ? { systems } : {}),
|
|
1669
1772
|
}),
|
|
1670
1773
|
);
|
package/engine/frontmatter.mjs
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// build package rather than restated here, so the pipeline and the runtime
|
|
31
31
|
// enum cannot drift apart — a value absent from the list is a build
|
|
32
32
|
// error, never a silent ship.
|
|
33
|
+
import { AddressEntries } from "./address-values.mjs";
|
|
33
34
|
import { AFFILIATION_STANDINGS } from "../sohl/affiliation-standings.mjs";
|
|
34
35
|
|
|
35
36
|
/**
|
|
@@ -87,20 +88,48 @@ export function sohlField(fm, key, defaultValue = undefined) {
|
|
|
87
88
|
* has to be told both. It is the same split `FieldSpec.name`/`legacyKey` makes,
|
|
88
89
|
* for the same reason — one name cannot key two positions.
|
|
89
90
|
*
|
|
91
|
+
* `shared` is the field's declared shared source, and it completes the order the
|
|
92
|
+
* three other positions are two thirds of. A field whose source is a path into
|
|
93
|
+
* `data:` is authored there by the specification's own mapping tables, and a
|
|
94
|
+
* reader that knew the destination, the block and the top level alone skipped
|
|
95
|
+
* straight past the container: every authored value read as unset, and the
|
|
96
|
+
* document shipped the field's default with nothing said. It sits **between**
|
|
97
|
+
* the block and the top level, exactly where
|
|
98
|
+
* {@link module:engine/system-block.resolveFieldValue} puts it — the block is the
|
|
99
|
+
* position a tree still writes and wins while it does, and the bare top-level
|
|
100
|
+
* key is the spelling `data:` gathered the fact off.
|
|
101
|
+
*
|
|
90
102
|
* @param {object} fm - The note's frontmatter.
|
|
91
103
|
* @param {string} to - The key at the destination, dotted for a nested one.
|
|
92
104
|
* @param {any} [defaultValue] - What an unauthored field reads as.
|
|
93
105
|
* @param {object} [options] - Options.
|
|
94
106
|
* @param {string} [options.legacyKey] - The key the block still carries, when
|
|
95
107
|
* it is not spelled `to`. Defaults to `to`.
|
|
108
|
+
* @param {string} [options.shared] - The field's declared shared source, when it
|
|
109
|
+
* is a path into a container (`data.relations`). Omitted by a field whose
|
|
110
|
+
* shared position is the note's top level, which `sohlField` already reads.
|
|
96
111
|
* @returns {any} The value.
|
|
97
112
|
*/
|
|
98
|
-
export function sohlSystemField(fm, to, defaultValue = undefined, { legacyKey = to } = {}) {
|
|
113
|
+
export function sohlSystemField(fm, to, defaultValue = undefined, { legacyKey = to, shared } = {}) {
|
|
99
114
|
const system = fm?.sohl?.system;
|
|
100
115
|
if (system && typeof system === "object" && !Array.isArray(system)) {
|
|
101
116
|
const found = getFrontmatter(system, to, undefined);
|
|
102
117
|
if (found !== undefined) return found;
|
|
103
118
|
}
|
|
119
|
+
if (shared !== undefined) {
|
|
120
|
+
const block = fm?.sohl;
|
|
121
|
+
const inBlock =
|
|
122
|
+
block && typeof block === "object" ?
|
|
123
|
+
getFrontmatter(block, legacyKey, undefined)
|
|
124
|
+
: undefined;
|
|
125
|
+
// Consulted only where the block does not carry the key, so step 2 keeps
|
|
126
|
+
// winning; `sohlField` below then reads the block and the retiring
|
|
127
|
+
// top-level spelling exactly as it always has.
|
|
128
|
+
if (inBlock === undefined) {
|
|
129
|
+
const container = getFrontmatter(fm, shared, undefined);
|
|
130
|
+
if (container !== undefined) return container;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
104
133
|
return sohlField(fm, legacyKey, defaultValue);
|
|
105
134
|
}
|
|
106
135
|
|
|
@@ -122,12 +151,16 @@ export function sohlSystemField(fm, to, defaultValue = undefined, { legacyKey =
|
|
|
122
151
|
*
|
|
123
152
|
* @param {object} fm - The item frontmatter.
|
|
124
153
|
* @param {string} key - The property name, read via {@link sohlSystemField}.
|
|
154
|
+
* @param {object} [options] - Forwarded to {@link sohlSystemField}.
|
|
155
|
+
* @param {string} [options.shared] - The field's declared shared source, when it
|
|
156
|
+
* is a path into a container.
|
|
125
157
|
* @returns {[string, unknown][] | null} The property's entries — empty when the
|
|
126
158
|
* note authors none — or `null` when the value is not a map.
|
|
127
159
|
*/
|
|
128
|
-
function readMapEntries(fm, key) {
|
|
129
|
-
const raw = sohlSystemField(fm, key, undefined);
|
|
160
|
+
function readMapEntries(fm, key, { shared } = {}) {
|
|
161
|
+
const raw = sohlSystemField(fm, key, undefined, { shared });
|
|
130
162
|
if (raw == null) return [];
|
|
163
|
+
if (raw instanceof AddressEntries) return raw.entries.map((e) => [e.target, e.value]);
|
|
131
164
|
if (Array.isArray(raw)) return raw.length === 0 ? [] : null;
|
|
132
165
|
if (typeof raw !== "object") return null;
|
|
133
166
|
return Object.entries(raw);
|
|
@@ -155,11 +188,16 @@ export function resolveCharges(fm) {
|
|
|
155
188
|
const num = Number(raw);
|
|
156
189
|
return Number.isFinite(num) ? Math.trunc(num) : null;
|
|
157
190
|
};
|
|
158
|
-
const max = toCount(sohlSystemField(fm, "charges.max", null));
|
|
191
|
+
const max = toCount(sohlSystemField(fm, "charges.max", null, { shared: "data.charges.max" }));
|
|
159
192
|
// A blank maximum means "does not use charges" — a stray current count
|
|
160
193
|
// cannot outlive it, since the logic layer disables both modifiers.
|
|
161
194
|
return {
|
|
162
|
-
value:
|
|
195
|
+
value:
|
|
196
|
+
max === null ? null : (
|
|
197
|
+
toCount(
|
|
198
|
+
sohlSystemField(fm, "charges.value", null, { shared: "data.charges.value" }),
|
|
199
|
+
)
|
|
200
|
+
),
|
|
163
201
|
max,
|
|
164
202
|
};
|
|
165
203
|
}
|
|
@@ -186,7 +224,7 @@ export function resolveCharges(fm) {
|
|
|
186
224
|
* @throws {Error} When the value is not a map, or a value is not an integer.
|
|
187
225
|
*/
|
|
188
226
|
export function resolveSkillAptitudes(fm, ctx = "item") {
|
|
189
|
-
const entries = readMapEntries(fm, "skillAptitudes");
|
|
227
|
+
const entries = readMapEntries(fm, "skillAptitudes", { shared: "data.skillAptitudes" });
|
|
190
228
|
if (entries === null) {
|
|
191
229
|
throw new Error(`${ctx}: skillAptitudes must be a map of selector → number`);
|
|
192
230
|
}
|
|
@@ -229,12 +267,18 @@ export function resolveRelation(fm, ctx = "item") {
|
|
|
229
267
|
// Probed at the destination too: a note that has moved to
|
|
230
268
|
// `sohl.system.relations` carries the current spelling, and a probe that
|
|
231
269
|
// could not see it would fall through to the retired one and read `{}`.
|
|
232
|
-
|
|
233
|
-
|
|
270
|
+
// Each spelling is probed at all four of its positions, the `data:` source
|
|
271
|
+
// the specification names included, so a retirement and a container move are
|
|
272
|
+
// independent of each other.
|
|
273
|
+
const current = sohlSystemField(fm, "relations", undefined, { shared: "data.relations" });
|
|
274
|
+
const key = current == null ? "relation" : "relations";
|
|
275
|
+
const entries = readMapEntries(fm, key, { shared: `data.${key}` });
|
|
234
276
|
if (entries === null) {
|
|
235
277
|
throw new Error(`${ctx}: ${key} must be a map of shortcode → standing`);
|
|
236
278
|
}
|
|
279
|
+
const typed = entries.some(([code]) => typeof code === "object");
|
|
237
280
|
const out = {};
|
|
281
|
+
const typedEntries = [];
|
|
238
282
|
for (const [code, value] of entries) {
|
|
239
283
|
const standing = String(value);
|
|
240
284
|
if (!AFFILIATION_STANDINGS.includes(standing)) {
|
|
@@ -242,9 +286,10 @@ export function resolveRelation(fm, ctx = "item") {
|
|
|
242
286
|
`${ctx}: ${key}["${code}"] must be one of ${AFFILIATION_STANDINGS.join(", ")}, got "${value}"`,
|
|
243
287
|
);
|
|
244
288
|
}
|
|
245
|
-
|
|
289
|
+
if (typed) typedEntries.push({ target: code, value: standing, sourceKey: "" });
|
|
290
|
+
else out[code] = standing;
|
|
246
291
|
}
|
|
247
|
-
return out;
|
|
292
|
+
return typed ? new AddressEntries(typedEntries) : out;
|
|
248
293
|
}
|
|
249
294
|
|
|
250
295
|
/**
|
package/engine/generate.mjs
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* HeroicLands vault is no longer a build input for SoHL content.
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
+
import { noteAddressContext } from "./note-addresses.mjs";
|
|
42
43
|
import fs from "fs";
|
|
43
44
|
import path from "path";
|
|
44
45
|
import log from "loglevel";
|
|
@@ -805,7 +806,12 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
|
|
|
805
806
|
)
|
|
806
807
|
.map((record) => {
|
|
807
808
|
const absPath = noteFile(contentBase, record);
|
|
808
|
-
return {
|
|
809
|
+
return {
|
|
810
|
+
frontmatter: parseMarkdownFile(absPath, {
|
|
811
|
+
addressContext: noteAddressContext(config),
|
|
812
|
+
}).frontmatter,
|
|
813
|
+
absPath,
|
|
814
|
+
};
|
|
809
815
|
}),
|
|
810
816
|
// The package this build resolved, not the ambient accessor:
|
|
811
817
|
// they are the same value in a real repository and different
|
package/engine/helpers.mjs
CHANGED
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
* journals.mjs, actors.mjs).
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { buildReferenceTargets } from "./reference-targets.mjs";
|
|
29
|
+
|
|
30
|
+
import { decodeNoteAddresses } from "./note-addresses.mjs";
|
|
31
|
+
import { positionOfYamlPath } from "./diagnostics.mjs";
|
|
32
|
+
import { completeAddress, renderAddress } from "./address.mjs";
|
|
33
|
+
import { encodeAddresses } from "./address-values.mjs";
|
|
28
34
|
import fs from "fs";
|
|
29
35
|
import crypto from "crypto";
|
|
30
36
|
import path from "path";
|
|
@@ -131,7 +137,7 @@ export const md = markdownit({ html: true })
|
|
|
131
137
|
* `{ frontmatter: null, body: "", description: "" }` with a warn log, and no
|
|
132
138
|
* position: there is no body to have one.
|
|
133
139
|
*/
|
|
134
|
-
export function parseMarkdownFile(filePath) {
|
|
140
|
+
export function parseMarkdownFile(filePath, { addressContext } = {}) {
|
|
135
141
|
const content = fs.readFileSync(filePath, "utf8");
|
|
136
142
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
137
143
|
if (!fmMatch) {
|
|
@@ -144,6 +150,19 @@ export function parseMarkdownFile(filePath) {
|
|
|
144
150
|
log.warn(`YAML parse error in ${filePath}: ${err.message}`);
|
|
145
151
|
return { frontmatter: null, body: "", description: "" };
|
|
146
152
|
}
|
|
153
|
+
if (addressContext) {
|
|
154
|
+
try {
|
|
155
|
+
decodeNoteAddresses(frontmatter, addressContext);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const position = positionOfYamlPath(fmMatch[1], error.keyPath ?? [], {
|
|
158
|
+
key: error.addressKey,
|
|
159
|
+
});
|
|
160
|
+
if (position.line) position.line++;
|
|
161
|
+
error.file = filePath;
|
|
162
|
+
error.position = position;
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
147
166
|
const raw = fmMatch[2];
|
|
148
167
|
const body = raw.trim();
|
|
149
168
|
const description = body ? md.render(body) : "";
|
|
@@ -238,7 +257,7 @@ export function assertSuppliedCorpus(records, who) {
|
|
|
238
257
|
* @throws {Error} When `skipDirectories` is not stated — see
|
|
239
258
|
* {@link assertStatedScope}.
|
|
240
259
|
*/
|
|
241
|
-
export function* walkMarkdownTree(rootDir, { skipDirectories } = {}) {
|
|
260
|
+
export function* walkMarkdownTree(rootDir, { skipDirectories, addressContext } = {}) {
|
|
242
261
|
// Stated by the caller, never resolved here. A default here
|
|
243
262
|
// — `loadPackConfig().skipDirectories` — read whichever configuration
|
|
244
263
|
// resolved from the working directory rather than the one the caller was
|
|
@@ -269,7 +288,7 @@ export function* walkMarkdownTree(rootDir, { skipDirectories } = {}) {
|
|
|
269
288
|
stack.push(absPath);
|
|
270
289
|
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
271
290
|
yield {
|
|
272
|
-
...parseMarkdownFile(absPath),
|
|
291
|
+
...parseMarkdownFile(absPath, { addressContext }),
|
|
273
292
|
file: entry.name,
|
|
274
293
|
absPath,
|
|
275
294
|
};
|
|
@@ -702,12 +721,16 @@ export function buildContentLinkIndex(
|
|
|
702
721
|
const assets = new Map();
|
|
703
722
|
const resolved = config ?? loadPackConfig();
|
|
704
723
|
assertSuppliedCorpus(records, "buildContentLinkIndex");
|
|
724
|
+
const recordsByAddress = new Map(
|
|
725
|
+
records.map((record) => [encodeAddresses(record.address?.canonical), record]),
|
|
726
|
+
);
|
|
705
727
|
for (const record of records) {
|
|
706
728
|
// An asset's record addresses a file rather than a note, so it becomes
|
|
707
729
|
// no `doc` and takes no part in link resolution — it is keyed for the
|
|
708
730
|
// art fields, which name a file and never a document.
|
|
709
731
|
if (isAssetRecord(record)) {
|
|
710
|
-
if (record.address?.canonical)
|
|
732
|
+
if (record.address?.canonical)
|
|
733
|
+
assets.set(encodeAddresses(record.address.canonical), record);
|
|
711
734
|
continue;
|
|
712
735
|
}
|
|
713
736
|
// A documentation journal is a document this tree emits, not a note in
|
|
@@ -734,7 +757,9 @@ export function buildContentLinkIndex(
|
|
|
734
757
|
// several frames deeper with nothing to go on.
|
|
735
758
|
assertTypeNotRetired(fm.type, absPath);
|
|
736
759
|
const base = String(record.file.name).replace(/_/g, " ");
|
|
760
|
+
const documentation = recordsByAddress.get(encodeAddresses(record.documentation));
|
|
737
761
|
docs.push({
|
|
762
|
+
documentationUuid: documentation?.foundry?.none?.uuid ?? null,
|
|
738
763
|
type: fm.type,
|
|
739
764
|
id: fm.id,
|
|
740
765
|
// Where this note's own document lands, and where the JournalEntry
|
|
@@ -770,11 +795,11 @@ export function buildContentLinkIndex(
|
|
|
770
795
|
// a contributor without every repository checked out resolves the same
|
|
771
796
|
// links CI does — from an artifact the producer shipped rather than a copy
|
|
772
797
|
// this repository committed.
|
|
773
|
-
const {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
);
|
|
798
|
+
const {
|
|
799
|
+
index: foreign,
|
|
800
|
+
references: foreignReferences,
|
|
801
|
+
stale,
|
|
802
|
+
} = loadForeignIndexes(resolved, [resolved.contentPackage], PACKAGE_BASE);
|
|
778
803
|
if (stale.length) {
|
|
779
804
|
for (const st of stale) {
|
|
780
805
|
log.error(`Unusable content index for "${st.package}": ${st.reason}`);
|
|
@@ -788,7 +813,10 @@ export function buildContentLinkIndex(
|
|
|
788
813
|
`Wikilink index: ${docs.length} local document(s), ` +
|
|
789
814
|
`${foreign.size} foreign address(es)`,
|
|
790
815
|
);
|
|
816
|
+
const referenceTargets = buildReferenceTargets(records);
|
|
791
817
|
return buildWikilinkIndex(docs, resolved.foundryPackage, foreign, resolved.contentPackage, {
|
|
818
|
+
referenceTargets,
|
|
819
|
+
foreignReferences,
|
|
792
820
|
assets,
|
|
793
821
|
noIndexPackages: noContentIndexPackages(resolved),
|
|
794
822
|
});
|