@heroiclands/package-build 22.9.1 → 22.10.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 +54 -0
- package/CONTENT.md +33 -0
- package/bin/content-build.mjs +3 -1
- package/docs/api.md +10 -6
- package/docs/content-format.md +384 -200
- package/docs/project-setup.md +23 -0
- package/engine/actor-compiler.mjs +20 -7
- package/engine/address-fields.mjs +404 -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 +59 -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 +20 -4
- package/engine/folder-notes.mjs +44 -15
- package/engine/foundry-entries.mjs +3 -0
- package/engine/frontmatter-lint.mjs +144 -43
- 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 +310 -0
- package/engine/note-vocabulary.mjs +145 -26
- 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/githooks/attribution.mjs +82 -0
- package/githooks/commit-message.mjs +26 -0
- package/githooks/commit-msg +3 -35
- package/hm3/actors.mjs +8 -24
- package/hm3/item-fields.mjs +15 -5
- package/package.json +1 -1
- package/sohl/infobox.mjs +80 -17
- package/sohl/item-fields.mjs +114 -41
- package/types/engine/address-fields.d.mts +90 -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 +44 -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 +4 -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;
|
|
@@ -504,7 +491,7 @@ function checkDataContainer(note, { type, fields, packs }) {
|
|
|
504
491
|
value = value && typeof value === "object" ? value[segment] : undefined;
|
|
505
492
|
}
|
|
506
493
|
if (value === undefined || value === null) continue;
|
|
507
|
-
if (!matchesKind(value, field.kind)) {
|
|
494
|
+
if (!matchesKind(value, field.kind, { ...addressContext, type: field.ref })) {
|
|
508
495
|
findings.push({
|
|
509
496
|
file: note.file,
|
|
510
497
|
...positionOfFrontmatterPath(raw, ["data", ...segments]),
|
|
@@ -515,6 +502,7 @@ function checkDataContainer(note, { type, fields, packs }) {
|
|
|
515
502
|
});
|
|
516
503
|
continue;
|
|
517
504
|
}
|
|
505
|
+
findings.push(...checkDataReferences(note, field, value, segments, addressContext));
|
|
518
506
|
// A `scalar-or-map` written in its map form is checked entry by entry,
|
|
519
507
|
// because that is the correction an author has to make: one key's
|
|
520
508
|
// value, not the whole map. Quoting the map back would name every
|
|
@@ -530,6 +518,76 @@ function checkDataContainer(note, { type, fields, packs }) {
|
|
|
530
518
|
return findings;
|
|
531
519
|
}
|
|
532
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Validate typed entries without resolving their targets.
|
|
523
|
+
* @param {object} note - The authored note.
|
|
524
|
+
* @param {object} field - Its data field declaration.
|
|
525
|
+
* @param {unknown} value - The field value.
|
|
526
|
+
* @param {string[]} segments - The field's path under `data`.
|
|
527
|
+
* @param {object} context - The builder's Address context.
|
|
528
|
+
* @returns {object[]} Located findings.
|
|
529
|
+
*/
|
|
530
|
+
function checkDataReferences(note, field, value, segments, context) {
|
|
531
|
+
const checks = [];
|
|
532
|
+
if (field.kind === "address") checks.push({ value, path: [], kind: "address" });
|
|
533
|
+
if (field.entryKind) {
|
|
534
|
+
if (Array.isArray(value)) {
|
|
535
|
+
value.forEach((entry, i) =>
|
|
536
|
+
checks.push({ value: entry, path: [i], kind: field.entryKind }),
|
|
537
|
+
);
|
|
538
|
+
} else if (value && typeof value === "object" && !isAddressTuple(value)) {
|
|
539
|
+
Object.entries(value).forEach(([key, entry]) => {
|
|
540
|
+
if (entry != null)
|
|
541
|
+
checks.push({ value: entry, path: [key], kind: field.entryKind, atKey: true });
|
|
542
|
+
});
|
|
543
|
+
} else checks.push({ value, path: [], kind: field.entryKind });
|
|
544
|
+
}
|
|
545
|
+
if (field.keyKind) {
|
|
546
|
+
if (value instanceof AddressEntries) {
|
|
547
|
+
for (const entry of value.entries)
|
|
548
|
+
checks.push({
|
|
549
|
+
value: entry.target,
|
|
550
|
+
path: [entry.sourceKey],
|
|
551
|
+
kind: field.keyKind,
|
|
552
|
+
key: true,
|
|
553
|
+
});
|
|
554
|
+
} else
|
|
555
|
+
Object.keys(value).forEach((key) =>
|
|
556
|
+
checks.push({ value: key, path: [key], kind: field.keyKind, key: true }),
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
const findings = [];
|
|
560
|
+
const defaults = { ...context, type: field.ref };
|
|
561
|
+
const accepted = field.accepts;
|
|
562
|
+
for (const check of checks) {
|
|
563
|
+
if (
|
|
564
|
+
check.key &&
|
|
565
|
+
field.keySelector === "subType" &&
|
|
566
|
+
typeof check.value === "string" &&
|
|
567
|
+
check.value.startsWith("subType:") &&
|
|
568
|
+
isAddressSegment(check.value.slice("subType:".length))
|
|
569
|
+
)
|
|
570
|
+
continue;
|
|
571
|
+
let reason;
|
|
572
|
+
if (!matchesKind(check.value, check.kind, defaults))
|
|
573
|
+
reason = `should be ${check.kind === "address" ? "an Address" : "a Shortcode"}`;
|
|
574
|
+
else if (check.kind === "address" && accepted) {
|
|
575
|
+
const tuple = parseAddress(check.value, defaults, { declared: true });
|
|
576
|
+
if (!acceptsType(tuple, accepted))
|
|
577
|
+
reason = `accepts ${accepted.join(" or ")}, not ${tuple.type}`;
|
|
578
|
+
}
|
|
579
|
+
if (!reason) continue;
|
|
580
|
+
const path = ["data", ...segments, ...check.path];
|
|
581
|
+
findings.push({
|
|
582
|
+
file: note.file,
|
|
583
|
+
...positionOfFrontmatterPath(note.raw ?? "", path, { key: check.key || check.atKey }),
|
|
584
|
+
severity: "error",
|
|
585
|
+
message: `\`${path.join(".")}\` ${reason}, but reads ${JSON.stringify(check.value)}`,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return findings;
|
|
589
|
+
}
|
|
590
|
+
|
|
533
591
|
/**
|
|
534
592
|
* Check one `scalar-or-map` field written in its map form, entry by entry.
|
|
535
593
|
*
|
|
@@ -578,7 +636,7 @@ function checkKeyedMap(note, { field, segments, entries, raw, packs }) {
|
|
|
578
636
|
// An explicit `~` under a key is a statement, not an omission: it says
|
|
579
637
|
// "at the root there", which is different from saying nothing.
|
|
580
638
|
if (value === undefined || value === null) continue;
|
|
581
|
-
if (matchesKind(value, "string")) continue;
|
|
639
|
+
if (field.entryKind || matchesKind(value, "string")) continue;
|
|
582
640
|
findings.push({
|
|
583
641
|
file: note.file,
|
|
584
642
|
// On the key, not the value: an entry whose value is itself a map
|
|
@@ -1000,7 +1058,7 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
1000
1058
|
* @param {object} opts
|
|
1001
1059
|
* @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
|
|
1002
1060
|
* @param {object} [opts.index] - The link index, for the reference check, which
|
|
1003
|
-
* runs through its `
|
|
1061
|
+
* runs through its `shortcodeHit`. Its absence skips that check rather than
|
|
1004
1062
|
* reporting every reference as dead.
|
|
1005
1063
|
* @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
|
|
1006
1064
|
* it declares, as `engine/note-vocabulary.mjs` states them. Supplied
|
|
@@ -1019,6 +1077,7 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
1019
1077
|
* for the same reason `schemas` is — a build derives them from its
|
|
1020
1078
|
* configuration through {@link systemBlocksFor}, and this module states no
|
|
1021
1079
|
* system name of its own. See {@link DEFAULT_SYSTEM_BLOCKS} for the fallback.
|
|
1080
|
+
* @param {object} [opts.addressContext] - Builder package and Address type vocabulary.
|
|
1022
1081
|
* @param {readonly string[]} [opts.packs] - The pack names this package
|
|
1023
1082
|
* declares, for a `data:` field whose map is keyed by pack. Supplied by the
|
|
1024
1083
|
* caller like `schemas` and `vocabulary`, and absent it no claim is made
|
|
@@ -1027,10 +1086,29 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
1027
1086
|
*/
|
|
1028
1087
|
export function lintNote(
|
|
1029
1088
|
note,
|
|
1030
|
-
{
|
|
1089
|
+
{
|
|
1090
|
+
schemas,
|
|
1091
|
+
index,
|
|
1092
|
+
vocabulary,
|
|
1093
|
+
packs,
|
|
1094
|
+
emittedArt,
|
|
1095
|
+
addressContext,
|
|
1096
|
+
systems = DEFAULT_SYSTEM_BLOCKS,
|
|
1097
|
+
},
|
|
1031
1098
|
) {
|
|
1032
1099
|
const findings = [];
|
|
1033
1100
|
const fm = note.fm ?? {};
|
|
1101
|
+
const referenceContext = {
|
|
1102
|
+
package: index?.contentPackage,
|
|
1103
|
+
system: "none",
|
|
1104
|
+
...addressContext,
|
|
1105
|
+
types: new Set([
|
|
1106
|
+
...Object.keys(vocabulary ?? {}),
|
|
1107
|
+
...ASSET_TYPE_NAMES,
|
|
1108
|
+
...(index?.types ?? []),
|
|
1109
|
+
...(addressContext?.types ?? []),
|
|
1110
|
+
]),
|
|
1111
|
+
};
|
|
1034
1112
|
const type = String(fm.type ?? "");
|
|
1035
1113
|
const raw = () => note.raw ?? "";
|
|
1036
1114
|
const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
|
|
@@ -1197,7 +1275,7 @@ export function lintNote(
|
|
|
1197
1275
|
inData,
|
|
1198
1276
|
blockCollides: blockCollisions.has(key),
|
|
1199
1277
|
});
|
|
1200
|
-
if (typeof authored !== "string") continue;
|
|
1278
|
+
if (typeof authored !== "string" && !isAddressTuple(authored)) continue;
|
|
1201
1279
|
findings.push({
|
|
1202
1280
|
file: note.file,
|
|
1203
1281
|
...at(key),
|
|
@@ -1225,7 +1303,7 @@ export function lintNote(
|
|
|
1225
1303
|
inData,
|
|
1226
1304
|
blockCollides: blockCollisions.has(key),
|
|
1227
1305
|
});
|
|
1228
|
-
if (typeof authored !== "string") continue;
|
|
1306
|
+
if (typeof authored !== "string" && !isAddressTuple(authored)) continue;
|
|
1229
1307
|
const problem = pathnameProblem(authored);
|
|
1230
1308
|
if (!problem) continue;
|
|
1231
1309
|
findings.push({
|
|
@@ -1438,6 +1516,7 @@ export function lintNote(
|
|
|
1438
1516
|
// the container check would have to be told about.
|
|
1439
1517
|
fields,
|
|
1440
1518
|
packs,
|
|
1519
|
+
addressContext: referenceContext,
|
|
1441
1520
|
}),
|
|
1442
1521
|
);
|
|
1443
1522
|
findings.push(...checkSubType(note, { type, entry }));
|
|
@@ -1605,20 +1684,37 @@ export function lintNote(
|
|
|
1605
1684
|
continue;
|
|
1606
1685
|
}
|
|
1607
1686
|
|
|
1608
|
-
// A
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1687
|
+
// A `code:` field's value is a Shortcode, not an Address: one segment,
|
|
1688
|
+
// persisted verbatim and resolved at runtime among the items embedded
|
|
1689
|
+
// on one actor, where packages do not exist. The charset is checked
|
|
1690
|
+
// first, because a value carrying the separator cannot mean what this
|
|
1691
|
+
// position asks for, whatever the field supplies as the type — and the
|
|
1692
|
+
// message says so, rather than reporting a lookup failure for a value
|
|
1693
|
+
// that was never going to resolve. Once it passes, the field's type and
|
|
1694
|
+
// the value form the `(type, shortcode)` pair the resolver takes; it
|
|
1695
|
+
// resolves in any reachable package, because the value comes from
|
|
1696
|
+
// every package the actor draws on.
|
|
1697
|
+
const codeType = field.code;
|
|
1698
|
+
if (codeType && typeof value === "string" && value) {
|
|
1699
|
+
if (!isAddressSegment(value)) {
|
|
1700
|
+
findings.push({
|
|
1701
|
+
file: note.file,
|
|
1702
|
+
...at(head, value),
|
|
1703
|
+
severity: "error",
|
|
1704
|
+
message:
|
|
1705
|
+
`${label} must be a shortcode — the ${codeType}'s ` +
|
|
1706
|
+
`\`shortcode\`, not an address — because this position ` +
|
|
1707
|
+
`resolves it at runtime among one actor's embedded ` +
|
|
1708
|
+
`items, where packages do not exist, but reads ` +
|
|
1709
|
+
`${JSON.stringify(value)}`,
|
|
1710
|
+
});
|
|
1711
|
+
} else if (index && !index.shortcodeHit(codeType, value)) {
|
|
1616
1712
|
findings.push({
|
|
1617
1713
|
file: note.file,
|
|
1618
1714
|
...at(head, value),
|
|
1619
1715
|
severity: "error",
|
|
1620
1716
|
message:
|
|
1621
|
-
`${label} names ${
|
|
1717
|
+
`${label} names ${codeType} ` +
|
|
1622
1718
|
`"${value}", and no note or fetched index declares it`,
|
|
1623
1719
|
});
|
|
1624
1720
|
}
|
|
@@ -1665,6 +1761,11 @@ export function lintFrontmatter(
|
|
|
1665
1761
|
packs,
|
|
1666
1762
|
emittedArt,
|
|
1667
1763
|
index: references ? index : undefined,
|
|
1764
|
+
addressContext: {
|
|
1765
|
+
package: index.contentPackage,
|
|
1766
|
+
system: "none",
|
|
1767
|
+
types: index.types,
|
|
1768
|
+
},
|
|
1668
1769
|
...(systems ? { systems } : {}),
|
|
1669
1770
|
}),
|
|
1670
1771
|
);
|
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
|
});
|