@heroiclands/package-build 17.1.0 → 18.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 +1773 -0
- package/CONTENT.md +288 -72
- package/README.md +1 -1
- package/bin/content-build.mjs +218 -137
- package/bin/package-build.mjs +43 -0
- package/content-config.mjs +62 -67
- package/docs/content-format.md +768 -58
- package/engine/actor-compiler.mjs +586 -0
- package/engine/address-charset.mjs +11 -8
- package/engine/address-diff.mjs +266 -22
- package/engine/anchored-sections.mjs +83 -0
- package/engine/anchors.mjs +83 -0
- package/engine/base-compiler.mjs +148 -18
- package/engine/bundle-notes.mjs +276 -0
- package/engine/bundles.mjs +307 -0
- package/engine/code-fences.mjs +103 -0
- package/engine/compile-corpus.mjs +89 -0
- package/engine/content-address.mjs +267 -5
- package/engine/content-format-check.mjs +54 -11
- package/engine/content-format.mjs +37 -5
- package/engine/content-index.mjs +394 -111
- package/engine/content-links.mjs +185 -77
- package/engine/content-lint.mjs +232 -14
- package/engine/content-package.mjs +2 -1
- package/engine/content-tables.mjs +99 -3
- package/engine/document-subtypes.mjs +49 -6
- package/engine/field-reference.mjs +1 -1
- package/engine/field-spec.mjs +56 -6
- package/engine/folder-notes.mjs +470 -0
- package/engine/foreign-catalog.mjs +195 -4
- package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
- package/engine/frontmatter-lint.mjs +245 -19
- package/engine/frontmatter.mjs +88 -8
- package/engine/generate.mjs +304 -13
- package/engine/helpers.mjs +293 -64
- package/engine/ids.mjs +137 -2
- package/engine/index-records.mjs +126 -0
- package/engine/index.mjs +23 -5
- package/engine/item-compiler.mjs +349 -0
- package/engine/item-docs.mjs +7 -1
- package/engine/item-registry.mjs +6 -0
- package/engine/journals.mjs +93 -27
- package/engine/macros.mjs +4 -2
- package/engine/metadata-index.mjs +495 -0
- package/engine/note-claims.mjs +146 -25
- package/engine/note-ids.mjs +112 -0
- package/engine/note-renames.mjs +134 -0
- package/engine/note-schemas.mjs +25 -0
- package/engine/note-vocabulary.mjs +110 -27
- package/engine/pack-config.mjs +4 -0
- package/engine/pack-router.mjs +23 -0
- package/engine/retired-fields.mjs +137 -3
- package/engine/scenes.mjs +66 -34
- package/engine/schema-check.mjs +58 -17
- package/engine/site-build.mjs +71 -12
- package/engine/site-index.mjs +20 -7
- package/engine/sql-tables.mjs +485 -0
- package/engine/subtype-registry.mjs +102 -0
- package/engine/system-block.mjs +96 -12
- package/engine/systems.mjs +201 -0
- package/engine/web-wikilinks.mjs +50 -20
- package/engine/wikilink-syntax.mjs +17 -8
- package/engine/wikilinks.mjs +124 -51
- package/engine/yaml-lint.mjs +282 -0
- package/hm3/actors.mjs +352 -0
- package/hm3/default-item-art.mjs +75 -0
- package/hm3/document-subtypes.mjs +134 -0
- package/hm3/index.mjs +56 -0
- package/hm3/item-builders.mjs +84 -0
- package/hm3/item-fields.mjs +180 -0
- package/hm3/items.mjs +96 -0
- package/hm3/template-priority.mjs +85 -0
- package/labels.mjs +191 -0
- package/manifest.mjs +43 -2
- package/package.json +19 -4
- package/release.mjs +62 -7
- package/sohl/actors.mjs +33 -487
- package/sohl/being-info.mjs +16 -7
- package/sohl/default-item-art.mjs +14 -3
- package/sohl/document-subtypes.mjs +16 -10
- package/sohl/item-builders.mjs +14 -5
- package/sohl/item-fields.mjs +68 -7
- package/sohl/items.mjs +44 -258
- package/sohl/note-schemas.mjs +2 -2
- package/types/content-config.d.mts +21 -42
- package/types/engine/actor-compiler.d.mts +204 -0
- package/types/engine/address-charset.d.mts +11 -8
- package/types/engine/address-diff.d.mts +53 -5
- package/types/engine/anchored-sections.d.mts +21 -0
- package/types/engine/anchors.d.mts +20 -0
- package/types/engine/base-compiler.d.mts +17 -17
- package/types/engine/bundle-notes.d.mts +173 -0
- package/types/engine/bundles.d.mts +60 -0
- package/types/engine/code-fences.d.mts +43 -0
- package/types/engine/compile-corpus.d.mts +32 -0
- package/types/engine/content-address.d.mts +205 -5
- package/types/engine/content-format-check.d.mts +6 -2
- package/types/engine/content-format.d.mts +57 -1
- package/types/engine/content-index.d.mts +64 -80
- package/types/engine/content-links.d.mts +52 -7
- package/types/engine/content-lint.d.mts +10 -1
- package/types/engine/content-package.d.mts +2 -1
- package/types/engine/content-tables.d.mts +21 -39
- package/types/engine/document-subtypes.d.mts +37 -3
- package/types/engine/field-spec.d.mts +76 -5
- package/types/engine/folder-notes.d.mts +159 -0
- package/types/engine/foreign-catalog.d.mts +53 -0
- package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
- package/types/engine/frontmatter-lint.d.mts +10 -2
- package/types/engine/frontmatter.d.mts +64 -0
- package/types/engine/generate.d.mts +38 -0
- package/types/engine/helpers.d.mts +94 -30
- package/types/engine/ids.d.mts +96 -0
- package/types/engine/index-records.d.mts +68 -0
- package/types/engine/index.d.mts +9 -3
- package/types/engine/item-compiler.d.mts +131 -0
- package/types/engine/journals.d.mts +47 -9
- package/types/engine/metadata-index.d.mts +226 -0
- package/types/engine/note-claims.d.mts +57 -11
- package/types/engine/note-ids.d.mts +38 -0
- package/types/engine/note-renames.d.mts +102 -0
- package/types/engine/note-vocabulary.d.mts +44 -8
- package/types/engine/retired-fields.d.mts +75 -0
- package/types/engine/scenes.d.mts +3 -2
- package/types/engine/schema-check.d.mts +25 -4
- package/types/engine/site-build.d.mts +4 -4
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +185 -0
- package/types/engine/subtype-registry.d.mts +49 -0
- package/types/engine/system-block.d.mts +40 -1
- package/types/engine/systems.d.mts +106 -0
- package/types/engine/web-wikilinks.d.mts +4 -2
- package/types/engine/wikilink-syntax.d.mts +10 -3
- package/types/engine/wikilinks.d.mts +41 -13
- package/types/engine/yaml-lint.d.mts +107 -0
- package/types/hm3/actors.d.mts +48 -0
- package/types/hm3/default-item-art.d.mts +42 -0
- package/types/hm3/document-subtypes.d.mts +24 -0
- package/types/hm3/index.d.mts +7 -0
- package/types/hm3/item-builders.d.mts +11 -0
- package/types/hm3/item-fields.d.mts +12 -0
- package/types/hm3/items.d.mts +23 -0
- package/types/hm3/template-priority.d.mts +21 -0
- package/types/manifest.d.mts +22 -1
- package/types/release.d.mts +9 -4
- package/types/sohl/actors.d.mts +7 -74
- package/types/sohl/being-info.d.mts +8 -27
- package/types/sohl/default-item-art.d.mts +5 -3
- package/types/sohl/items.d.mts +17 -32
- package/engine/foreign-manifests.mjs +0 -126
- package/engine/kb-manifest.mjs +0 -490
- package/types/engine/foreign-manifests.d.mts +0 -43
- package/types/engine/kb-manifest.d.mts +0 -241
|
@@ -55,8 +55,9 @@
|
|
|
55
55
|
* @module
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
|
-
import { authoredFields } from "./field-spec.mjs";
|
|
58
|
+
import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
|
|
59
59
|
import {
|
|
60
|
+
legacyKeyOf,
|
|
60
61
|
resolveFieldValue,
|
|
61
62
|
systemBlock,
|
|
62
63
|
SYSTEM_BLOCK_KEYS,
|
|
@@ -64,8 +65,12 @@ import {
|
|
|
64
65
|
} from "./system-block.mjs";
|
|
65
66
|
import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
66
67
|
import { checkHomepageAddressFields } from "./homepage.mjs";
|
|
67
|
-
import { RETIRED_TYPES } from "./ids.mjs";
|
|
68
|
+
import { RETIRED_TYPES, RENAMED_TYPES, currentType, renamedTypeMessage } from "./ids.mjs";
|
|
68
69
|
import { isAddressSegment } from "./address-charset.mjs";
|
|
70
|
+
// The one place the "every pack not named" key is spelled. Imported rather
|
|
71
|
+
// than repeated, because a linter holding its own copy of what the compiler
|
|
72
|
+
// reads is exactly the disagreement #288 was.
|
|
73
|
+
import { DEFAULT_PARENT } from "./folder-notes.mjs";
|
|
69
74
|
import { declaredTags, subTypeCharsetMessage, typeCharsetMessage } from "./note-vocabulary.mjs";
|
|
70
75
|
import {
|
|
71
76
|
RETIRED_FIELD_ALIASES,
|
|
@@ -73,9 +78,11 @@ import {
|
|
|
73
78
|
aliasesRetiredMessage,
|
|
74
79
|
declaresRetiredAliasesField,
|
|
75
80
|
draftRetiredMessage,
|
|
81
|
+
legacyKeyMessage,
|
|
76
82
|
readAliasedField,
|
|
77
83
|
retiredAliasMessage,
|
|
78
84
|
sectionRetiredMessage,
|
|
85
|
+
traitsRetiredMessage,
|
|
79
86
|
} from "./retired-fields.mjs";
|
|
80
87
|
|
|
81
88
|
/**
|
|
@@ -106,7 +113,9 @@ import {
|
|
|
106
113
|
*
|
|
107
114
|
* @type {ReadonlySet<string>}
|
|
108
115
|
*/
|
|
109
|
-
export const UNIVERSAL_KEYS = Object.freeze(
|
|
116
|
+
export const UNIVERSAL_KEYS = Object.freeze(
|
|
117
|
+
new Set(["folder", "packFolder", "pack", "archetype", "templatePriority", "kbcat"]),
|
|
118
|
+
);
|
|
110
119
|
|
|
111
120
|
/**
|
|
112
121
|
* The system blocks a build checks, and what each accepts beyond the shared
|
|
@@ -215,11 +224,33 @@ export function matchesKind(value, kind) {
|
|
|
215
224
|
(typeof value === "object" && value !== null && !Array.isArray(value)) ||
|
|
216
225
|
(Array.isArray(value) && value.length === 0)
|
|
217
226
|
);
|
|
227
|
+
case "scalar-or-map":
|
|
228
|
+
// A scalar, or a map of them. The map's *entries* are checked
|
|
229
|
+
// separately, by the caller that can name the key at fault; all
|
|
230
|
+
// this answers is whether the value has one of the two shapes the
|
|
231
|
+
// field admits. A list has neither.
|
|
232
|
+
return matchesKind(value, "string") || matchesKind(value, "map");
|
|
218
233
|
default:
|
|
219
234
|
return true;
|
|
220
235
|
}
|
|
221
236
|
}
|
|
222
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The entries of a `scalar-or-map` value written in its map form, or `null`
|
|
240
|
+
* where it was written as the scalar.
|
|
241
|
+
*
|
|
242
|
+
* The empty-list spelling of an emptied map ({@link matchesKind}) has no
|
|
243
|
+
* entries, so it reads the same as `{}` here too.
|
|
244
|
+
*
|
|
245
|
+
* @param {unknown} value - The authored value.
|
|
246
|
+
* @returns {Record<string, unknown>|null} Its entries, or `null` for a scalar.
|
|
247
|
+
*/
|
|
248
|
+
function mapEntries(value) {
|
|
249
|
+
if (Array.isArray(value)) return value.length === 0 ? {} : null;
|
|
250
|
+
if (typeof value !== "object" || value === null) return null;
|
|
251
|
+
return /** @type {Record<string, unknown>} */ (value);
|
|
252
|
+
}
|
|
253
|
+
|
|
223
254
|
/**
|
|
224
255
|
* The `data:` container a note authored.
|
|
225
256
|
*
|
|
@@ -260,9 +291,14 @@ function dataBlock(fm) {
|
|
|
260
291
|
* @param {object} opts
|
|
261
292
|
* @param {string} opts.type - The note's type, for the message.
|
|
262
293
|
* @param {readonly object[]} opts.fields - The type's `data:` declaration.
|
|
294
|
+
* @param {readonly string[]} [opts.packs] - The pack names this package
|
|
295
|
+
* declares, against which a `keys: "pack"` map's keys are checked. Absent,
|
|
296
|
+
* no claim is made about them: a caller that cannot see the configuration
|
|
297
|
+
* knows no pack names, and reporting every key as unknown because nothing
|
|
298
|
+
* was loaded to recognise it would be worse than not checking.
|
|
263
299
|
* @returns {object[]} Findings.
|
|
264
300
|
*/
|
|
265
|
-
function checkDataContainer(note, { type, fields }) {
|
|
301
|
+
function checkDataContainer(note, { type, fields, packs }) {
|
|
266
302
|
const findings = [];
|
|
267
303
|
const { present, entries, malformed } = dataBlock(note.fm ?? {});
|
|
268
304
|
if (!present) return findings;
|
|
@@ -306,13 +342,91 @@ function checkDataContainer(note, { type, fields }) {
|
|
|
306
342
|
value = value && typeof value === "object" ? value[segment] : undefined;
|
|
307
343
|
}
|
|
308
344
|
if (value === undefined || value === null) continue;
|
|
309
|
-
if (matchesKind(value, field.kind))
|
|
345
|
+
if (!matchesKind(value, field.kind)) {
|
|
346
|
+
findings.push({
|
|
347
|
+
file: note.file,
|
|
348
|
+
...positionOfFrontmatterPath(raw, ["data", ...segments]),
|
|
349
|
+
severity: "error",
|
|
350
|
+
message:
|
|
351
|
+
`\`data.${field.name}\` should be ${field.shape ?? field.kind}, ` +
|
|
352
|
+
`but reads ${JSON.stringify(value)}`,
|
|
353
|
+
});
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
// A `scalar-or-map` written in its map form is checked entry by entry,
|
|
357
|
+
// because that is the correction an author has to make: one key's
|
|
358
|
+
// value, not the whole map. Quoting the map back would name every
|
|
359
|
+
// entry that is right alongside the one that is not.
|
|
360
|
+
const written = field.kind === "scalar-or-map" ? mapEntries(value) : null;
|
|
361
|
+
if (written) {
|
|
362
|
+
findings.push(
|
|
363
|
+
...checkKeyedMap(note, { field, segments, entries: written, raw, packs }),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return findings;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Check one `scalar-or-map` field written in its map form, entry by entry.
|
|
373
|
+
*
|
|
374
|
+
* Two separate statements are checked, and they fail independently: whether a
|
|
375
|
+
* key names something — a pack, for `keys: "pack"` — and whether the value
|
|
376
|
+
* under it has the shape one entry is declared to have. A key nobody declares
|
|
377
|
+
* is not a harmless surplus: the compiler asks the map for the pack it is
|
|
378
|
+
* compiling and takes `default` when there is no such key, so a mistyped
|
|
379
|
+
* `journal:` silently files the folder wherever the default puts it, which is
|
|
380
|
+
* exactly the hierarchy the author wrote the key to override.
|
|
381
|
+
*
|
|
382
|
+
* @param {object} note - The note.
|
|
383
|
+
* @param {object} opts
|
|
384
|
+
* @param {object} opts.field - The field's declaration.
|
|
385
|
+
* @param {readonly string[]} opts.segments - Its path under `data:`.
|
|
386
|
+
* @param {Record<string, unknown>} opts.entries - The map's entries.
|
|
387
|
+
* @param {string} opts.raw - The note's raw text, for positions.
|
|
388
|
+
* @param {readonly string[]} [opts.packs] - The declared pack names, if known.
|
|
389
|
+
* @returns {object[]} Findings, one per offending entry.
|
|
390
|
+
*/
|
|
391
|
+
function checkKeyedMap(note, { field, segments, entries, raw, packs }) {
|
|
392
|
+
const findings = [];
|
|
393
|
+
const known = field.keys === "pack" && packs?.length ? new Set(packs) : undefined;
|
|
394
|
+
|
|
395
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
396
|
+
const path = ["data", ...segments, key];
|
|
397
|
+
const named = `data.${field.name}.${key}`;
|
|
398
|
+
|
|
399
|
+
// `default` is the map's own key for "every pack not named", not a
|
|
400
|
+
// pack — spelled out rather than left as an absent key, so a map
|
|
401
|
+
// stating only exceptions still reads as a complete answer (#276).
|
|
402
|
+
if (known && key !== DEFAULT_PARENT && !known.has(key)) {
|
|
403
|
+
const guess = nearest(key, known);
|
|
404
|
+
findings.push({
|
|
405
|
+
file: note.file,
|
|
406
|
+
...positionOfFrontmatterPath(raw, path, { key: true }),
|
|
407
|
+
severity: "error",
|
|
408
|
+
message:
|
|
409
|
+
`"${key}" is not a pack this package declares, so ` +
|
|
410
|
+
`\`${named}\` states a hierarchy nothing reads` +
|
|
411
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
412
|
+
});
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// An explicit `~` under a key is a statement, not an omission: it says
|
|
417
|
+
// "at the root there", which is different from saying nothing.
|
|
418
|
+
if (value === undefined || value === null) continue;
|
|
419
|
+
if (matchesKind(value, "string")) continue;
|
|
310
420
|
findings.push({
|
|
311
421
|
file: note.file,
|
|
312
|
-
|
|
422
|
+
// On the key, not the value: an entry whose value is itself a map
|
|
423
|
+
// begins on the *next* line, so pointing at the value lands a
|
|
424
|
+
// reader inside the thing that is wrong rather than on the entry
|
|
425
|
+
// the message names.
|
|
426
|
+
...positionOfFrontmatterPath(raw, path, { key: true }),
|
|
313
427
|
severity: "error",
|
|
314
428
|
message:
|
|
315
|
-
|
|
429
|
+
`\`${named}\` should be ${field.entryShape ?? "a scalar"}, ` +
|
|
316
430
|
`but reads ${JSON.stringify(value)}`,
|
|
317
431
|
});
|
|
318
432
|
}
|
|
@@ -554,7 +668,7 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
554
668
|
: entry.shortcode;
|
|
555
669
|
if (!type || !key) return;
|
|
556
670
|
|
|
557
|
-
const address = `${type}
|
|
671
|
+
const address = `${type}/${key}`;
|
|
558
672
|
const first = claimed.get(address);
|
|
559
673
|
if (first === undefined) {
|
|
560
674
|
claimed.set(address, index);
|
|
@@ -598,9 +712,16 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
598
712
|
* @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
|
|
599
713
|
* The system blocks to check, and what each accepts. See
|
|
600
714
|
* {@link DEFAULT_SYSTEM_BLOCKS}.
|
|
715
|
+
* @param {readonly string[]} [opts.packs] - The pack names this package
|
|
716
|
+
* declares, for a `data:` field whose map is keyed by pack. Supplied by the
|
|
717
|
+
* caller like `schemas` and `vocabulary`, and absent it no claim is made
|
|
718
|
+
* about those keys.
|
|
601
719
|
* @returns {object[]} Findings, each with a locator where one is obtainable.
|
|
602
720
|
*/
|
|
603
|
-
export function lintNote(
|
|
721
|
+
export function lintNote(
|
|
722
|
+
note,
|
|
723
|
+
{ schemas, index, vocabulary, packs, systems = DEFAULT_SYSTEM_BLOCKS },
|
|
724
|
+
) {
|
|
604
725
|
const findings = [];
|
|
605
726
|
const fm = note.fm ?? {};
|
|
606
727
|
const type = String(fm.type ?? "");
|
|
@@ -657,6 +778,29 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
657
778
|
// harmless. Whether `title: ""` deserves a warning of its own is a separate
|
|
658
779
|
// question about the *page's* heading, still open on #218, and not settled
|
|
659
780
|
// by extending an art-path check to it.
|
|
781
|
+
// The template priority is a *shared source* — the specification states it
|
|
782
|
+
// once for every type, as it does `pack` — so its retirement is reported
|
|
783
|
+
// here rather than by the per-type loop below, which only reaches a field
|
|
784
|
+
// some type's vocabulary declares (#266).
|
|
785
|
+
if (declaresRetiredAlias(fm, "templatePriority")) {
|
|
786
|
+
findings.push({
|
|
787
|
+
file: note.file,
|
|
788
|
+
...at(RETIRED_FIELD_ALIASES.templatePriority),
|
|
789
|
+
// An error, unlike the other retired alias. `archetype` is not
|
|
790
|
+
// a field of its own — it is `templatePriority` under its prior
|
|
791
|
+
// name, and both sit one letter from `archetypes`, which means
|
|
792
|
+
// something else entirely. A tree still on it is one where a
|
|
793
|
+
// priority and a taxonomy are told apart by a plural `s`, which
|
|
794
|
+
// is worth stopping rather than mentioning. This reds every
|
|
795
|
+
// tree until each is swept; that is the point.
|
|
796
|
+
severity: "error",
|
|
797
|
+
message: retiredAliasMessage(
|
|
798
|
+
RETIRED_FIELD_ALIASES.templatePriority,
|
|
799
|
+
"templatePriority",
|
|
800
|
+
),
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
660
804
|
for (const key of ART_FIELDS) {
|
|
661
805
|
if (authoredValue(fm, key) !== "") continue;
|
|
662
806
|
findings.push({
|
|
@@ -670,6 +814,29 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
670
814
|
"meant to have no image",
|
|
671
815
|
});
|
|
672
816
|
}
|
|
817
|
+
// `title: ""` publishes a blank heading (#218). The rule the art fields
|
|
818
|
+
// follow — `null` falls back, `""` is blank on purpose — reads the same way
|
|
819
|
+
// here, and for a *page heading* the deliberate blank is almost never what
|
|
820
|
+
// anyone wants: the emitter is `fm.title ?? name`, so `""` survives, the
|
|
821
|
+
// page publishes with no name, and it sorts to the front of its section
|
|
822
|
+
// landing ahead of every named page. Fifteen notes in `sohl-thalorna` are
|
|
823
|
+
// in exactly that state.
|
|
824
|
+
//
|
|
825
|
+
// A warning rather than an error: the value is legal under the rule, and a
|
|
826
|
+
// note that genuinely wants no heading may keep it — it just has to mean it.
|
|
827
|
+
if (authoredValue(fm, "title") === "") {
|
|
828
|
+
findings.push({
|
|
829
|
+
file: note.file,
|
|
830
|
+
...at("title"),
|
|
831
|
+
severity: "warning",
|
|
832
|
+
message:
|
|
833
|
+
'`title: ""` publishes a page with no heading, which sorts to ' +
|
|
834
|
+
"the front of its section ahead of every named page. Write " +
|
|
835
|
+
"`title: null` to fall back to `name.full`, or give the page a " +
|
|
836
|
+
'heading; keep `""` only where the blank is meant',
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
673
840
|
if (Object.hasOwn(fm, "draft")) {
|
|
674
841
|
findings.push({
|
|
675
842
|
file: note.file,
|
|
@@ -689,6 +856,18 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
689
856
|
message: sectionRetiredMessage(),
|
|
690
857
|
});
|
|
691
858
|
}
|
|
859
|
+
// Anchored at column 1 for the same reason `section` is, and with more at
|
|
860
|
+
// stake: `sohl.traits` is a *different field that shares the name* —
|
|
861
|
+
// `projectilegear` declares one and the theme's gear sidebar reads it — so
|
|
862
|
+
// a finding about the retired top-level block must never open on it (#291).
|
|
863
|
+
if (Object.hasOwn(fm, "traits")) {
|
|
864
|
+
findings.push({
|
|
865
|
+
file: note.file,
|
|
866
|
+
...positionInFrontmatter(raw(), "traits", undefined, { topLevel: true }),
|
|
867
|
+
severity: "error",
|
|
868
|
+
message: traitsRetiredMessage(),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
692
871
|
// Only the top-level `aliases` is retired. `name.aliases` writes the same
|
|
693
872
|
// key indented under `name:` and is **permitted** — reserved and unread —
|
|
694
873
|
// so both the test and the locator are anchored at column 1 (#180).
|
|
@@ -753,7 +932,27 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
753
932
|
return findings;
|
|
754
933
|
}
|
|
755
934
|
|
|
756
|
-
|
|
935
|
+
// A **renamed** type is the opposite case, and the opposite answer: the
|
|
936
|
+
// note compiles into exactly the document it always did, so refusing it
|
|
937
|
+
// would fail a build over a note that is not wrong. It is reported, and
|
|
938
|
+
// every lookup below reads the current spelling (#78).
|
|
939
|
+
const renamedTo = RENAMED_TYPES[type];
|
|
940
|
+
if (renamedTo) {
|
|
941
|
+
findings.push({
|
|
942
|
+
file: note.file,
|
|
943
|
+
...at("type", type),
|
|
944
|
+
// A warning, for the reason the retired *field* alias below is one:
|
|
945
|
+
// the sweep is the content trees' work and the refusal comes after
|
|
946
|
+
// it, as `package:`'s did (#56).
|
|
947
|
+
severity: "warning",
|
|
948
|
+
message: renamedTypeMessage(type, renamedTo),
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
// What every type-keyed table is keyed by. The authored spelling is still
|
|
952
|
+
// what a message quotes — it is what the reader has in front of them.
|
|
953
|
+
const current = currentType(type);
|
|
954
|
+
|
|
955
|
+
const schema = schemas[current];
|
|
757
956
|
if (!schema) {
|
|
758
957
|
findings.push({
|
|
759
958
|
file: note.file,
|
|
@@ -771,15 +970,22 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
771
970
|
// one this type may write. Skipped entirely when the caller declares no
|
|
772
971
|
// vocabulary — reporting every key as unknown because nothing was loaded
|
|
773
972
|
// to recognise it would be worse than not checking.
|
|
774
|
-
const entry = vocabulary?.[
|
|
973
|
+
const entry = vocabulary?.[current];
|
|
775
974
|
if (entry) {
|
|
776
|
-
findings.push(...checkDataContainer(note, { type, fields: entry.data ?? [] }));
|
|
975
|
+
findings.push(...checkDataContainer(note, { type, fields: entry.data ?? [], packs }));
|
|
777
976
|
findings.push(...checkSubType(note, { type, entry }));
|
|
778
977
|
}
|
|
779
978
|
|
|
780
979
|
const fields = authoredFields(schema);
|
|
781
|
-
/**
|
|
782
|
-
|
|
980
|
+
/**
|
|
981
|
+
* First segment of the key each field is authored at **inside the block** —
|
|
982
|
+
* `impact.die` is authored as `impact`, and a field whose shared source
|
|
983
|
+
* moved under `data:` is authored at the `legacyKey` it declares rather
|
|
984
|
+
* than at its dotted name (#305). Keying this on the name would report
|
|
985
|
+
* `sohl.species` as a property no `being` has, against exactly the notes
|
|
986
|
+
* the sweep has not reached yet.
|
|
987
|
+
*/
|
|
988
|
+
const declared = new Set(fields.map((f) => legacyKeyOf(f).split(".")[0]));
|
|
783
989
|
|
|
784
990
|
// The retired spelling of a field this type declares → what to write now.
|
|
785
991
|
// Built from the type's own vocabulary, so a renamed field is retired
|
|
@@ -865,11 +1071,28 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
865
1071
|
from = "block";
|
|
866
1072
|
}
|
|
867
1073
|
}
|
|
1074
|
+
// The sweep's progress signal (#305). A **warning**, for the reason a
|
|
1075
|
+
// retired spelling is one: the note compiles to the correct document,
|
|
1076
|
+
// so failing a build over it would red a tree that has done nothing
|
|
1077
|
+
// wrong yet. The refusal comes once no tree writes the position.
|
|
1078
|
+
if (readsLegacyKey(field, from)) {
|
|
1079
|
+
findings.push({
|
|
1080
|
+
file: note.file,
|
|
1081
|
+
...at(legacyKeyOf(field)),
|
|
1082
|
+
severity: "warning",
|
|
1083
|
+
message: legacyKeyMessage("sohl", field),
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
868
1086
|
const absent = from === "default" || value === undefined || value === null;
|
|
869
1087
|
// Where the field belongs, as a message names it: a shared field is not
|
|
870
1088
|
// under `sohl:`, so telling an author to write `sohl.img` would send
|
|
871
|
-
// them to the wrong region.
|
|
872
|
-
|
|
1089
|
+
// them to the wrong region. Nor is a field whose shared source is a
|
|
1090
|
+
// path into `data:` — `sohl.data.species` is a region that does not
|
|
1091
|
+
// exist, and the home of that field is the container it names (#305).
|
|
1092
|
+
const label =
|
|
1093
|
+
field.shared || (field.name.includes(".") && field.legacyKey !== undefined) ?
|
|
1094
|
+
`\`${field.name}\``
|
|
1095
|
+
: `\`sohl.${field.name}\``;
|
|
873
1096
|
|
|
874
1097
|
if (field.required && absent) {
|
|
875
1098
|
findings.push({
|
|
@@ -896,7 +1119,7 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
896
1119
|
|
|
897
1120
|
// A reference names another note by shortcode. Resolved through the
|
|
898
1121
|
// link index's own resolver, so a cross-package reference answered by a
|
|
899
|
-
//
|
|
1122
|
+
// fetched index lands exactly as the same address in a wikilink
|
|
900
1123
|
// would — rather than through a second, subtly different rule.
|
|
901
1124
|
//
|
|
902
1125
|
// **As an address, always** — which is now the only namespace there
|
|
@@ -914,7 +1137,7 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
914
1137
|
severity: "error",
|
|
915
1138
|
message:
|
|
916
1139
|
`${label} names ${field.ref} ` +
|
|
917
|
-
`"${value}", and no note or
|
|
1140
|
+
`"${value}", and no note or fetched index declares it`,
|
|
918
1141
|
});
|
|
919
1142
|
}
|
|
920
1143
|
}
|
|
@@ -934,10 +1157,12 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
934
1157
|
* @param {boolean} [opts.references=true] - Whether to check references.
|
|
935
1158
|
* @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
|
|
936
1159
|
* The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
|
|
1160
|
+
* @param {readonly string[]} [opts.packs] - The declared pack names; see
|
|
1161
|
+
* {@link lintNote}.
|
|
937
1162
|
* @returns {{findings: object[], notes: number}} The findings, and how many
|
|
938
1163
|
* notes were inspected.
|
|
939
1164
|
*/
|
|
940
|
-
export function lintFrontmatter(index, { schemas, vocabulary, references = true, systems }) {
|
|
1165
|
+
export function lintFrontmatter(index, { schemas, vocabulary, packs, references = true, systems }) {
|
|
941
1166
|
const findings = [];
|
|
942
1167
|
const notes = [...index.notes].sort((a, b) =>
|
|
943
1168
|
a.file < b.file ? -1
|
|
@@ -949,6 +1174,7 @@ export function lintFrontmatter(index, { schemas, vocabulary, references = true,
|
|
|
949
1174
|
...lintNote(note, {
|
|
950
1175
|
schemas,
|
|
951
1176
|
vocabulary,
|
|
1177
|
+
packs,
|
|
952
1178
|
index: references ? index : undefined,
|
|
953
1179
|
...(systems ? { systems } : {}),
|
|
954
1180
|
}),
|
package/engine/frontmatter.mjs
CHANGED
|
@@ -64,6 +64,46 @@ export function sohlField(fm, key, defaultValue = undefined) {
|
|
|
64
64
|
return getFrontmatter(fm, key, defaultValue);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Read a `sohl:` field, seeing the **destination** position as well (#126).
|
|
69
|
+
*
|
|
70
|
+
* Five declarations resolve their value by re-reading the note rather than by
|
|
71
|
+
* taking the one {@link module:engine/system-block.resolveFieldValue} handed
|
|
72
|
+
* them — `subType`, `charges`, a mystery's `skillAptitudes`, an affiliation's
|
|
73
|
+
* `relations` and a projectile's impact die — because each validates a *shape
|
|
74
|
+
* spread over several keys* rather than coercing one scalar.
|
|
75
|
+
*
|
|
76
|
+
* That was equivalent while every note authored inside the block, and stopped
|
|
77
|
+
* being so once a note may author at `sohl.system.<to>` instead: {@link
|
|
78
|
+
* sohlField} sees `sohl.<key>` and the top level, never inside `sohl.system`.
|
|
79
|
+
* So those five read as unset and shipped their empty value — a missing
|
|
80
|
+
* `subType` is a thrown build error, and the other four ship empty in silence,
|
|
81
|
+
* which is the failure class the passthrough exists to prevent.
|
|
82
|
+
*
|
|
83
|
+
* The **destination wins**, matching every other field's resolution order.
|
|
84
|
+
*
|
|
85
|
+
* `legacyKey` is for the one pair whose two positions are spelled differently:
|
|
86
|
+
* a projectile authors `impact.die` and stores `impactBase.die`, so the reader
|
|
87
|
+
* has to be told both. It is the same split `FieldSpec.name`/`legacyKey` makes
|
|
88
|
+
* (#305), for the same reason — one name cannot key two positions.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} fm - The note's frontmatter.
|
|
91
|
+
* @param {string} to - The key at the destination, dotted for a nested one.
|
|
92
|
+
* @param {any} [defaultValue] - What an unauthored field reads as.
|
|
93
|
+
* @param {object} [options] - Options.
|
|
94
|
+
* @param {string} [options.legacyKey] - The key the block still carries, when
|
|
95
|
+
* it is not spelled `to`. Defaults to `to`.
|
|
96
|
+
* @returns {any} The value.
|
|
97
|
+
*/
|
|
98
|
+
export function sohlSystemField(fm, to, defaultValue = undefined, { legacyKey = to } = {}) {
|
|
99
|
+
const system = fm?.sohl?.system;
|
|
100
|
+
if (system && typeof system === "object" && !Array.isArray(system)) {
|
|
101
|
+
const found = getFrontmatter(system, to, undefined);
|
|
102
|
+
if (found !== undefined) return found;
|
|
103
|
+
}
|
|
104
|
+
return sohlField(fm, legacyKey, defaultValue);
|
|
105
|
+
}
|
|
106
|
+
|
|
67
107
|
/**
|
|
68
108
|
* Read a frontmatter property that is authored as a **map**, returning its
|
|
69
109
|
* entries — or `null` when the note authors none.
|
|
@@ -81,12 +121,12 @@ export function sohlField(fm, key, defaultValue = undefined) {
|
|
|
81
121
|
* silent data loss these readers exist to prevent.
|
|
82
122
|
*
|
|
83
123
|
* @param {object} fm - The item frontmatter.
|
|
84
|
-
* @param {string} key - The property name, read via {@link
|
|
124
|
+
* @param {string} key - The property name, read via {@link sohlSystemField}.
|
|
85
125
|
* @returns {[string, unknown][] | null} The property's entries — empty when the
|
|
86
126
|
* note authors none — or `null` when the value is not a map.
|
|
87
127
|
*/
|
|
88
128
|
function readMapEntries(fm, key) {
|
|
89
|
-
const raw =
|
|
129
|
+
const raw = sohlSystemField(fm, key, undefined);
|
|
90
130
|
if (raw == null) return [];
|
|
91
131
|
if (Array.isArray(raw)) return raw.length === 0 ? [] : null;
|
|
92
132
|
if (typeof raw !== "object") return null;
|
|
@@ -115,11 +155,11 @@ export function resolveCharges(fm) {
|
|
|
115
155
|
const num = Number(raw);
|
|
116
156
|
return Number.isFinite(num) ? Math.trunc(num) : null;
|
|
117
157
|
};
|
|
118
|
-
const max = toCount(
|
|
158
|
+
const max = toCount(sohlSystemField(fm, "charges.max", null));
|
|
119
159
|
// A blank maximum means "does not use charges" — a stray current count
|
|
120
160
|
// cannot outlive it, since the logic layer disables both modifiers.
|
|
121
161
|
return {
|
|
122
|
-
value: max === null ? null : toCount(
|
|
162
|
+
value: max === null ? null : toCount(sohlSystemField(fm, "charges.value", null)),
|
|
123
163
|
max,
|
|
124
164
|
};
|
|
125
165
|
}
|
|
@@ -182,16 +222,24 @@ export function resolveSkillAptitudes(fm, ctx = "item") {
|
|
|
182
222
|
* @throws {Error} When the map is malformed or names an unknown standing.
|
|
183
223
|
*/
|
|
184
224
|
export function resolveRelation(fm, ctx = "item") {
|
|
185
|
-
|
|
225
|
+
// `relations` is the field's name; `relation` is the retired spelling, read
|
|
226
|
+
// underneath it so a tree converts on its own schedule (SoHL#1781). The
|
|
227
|
+
// current name wins wherever a note writes both, and the lint reports the
|
|
228
|
+
// old one through {@link RETIRED_FIELD_ALIASES}.
|
|
229
|
+
// Probed at the destination too: a note that has moved to
|
|
230
|
+
// `sohl.system.relations` carries the current spelling, and a probe that
|
|
231
|
+
// could not see it would fall through to the retired one and read `{}`.
|
|
232
|
+
const key = sohlSystemField(fm, "relations", undefined) == null ? "relation" : "relations";
|
|
233
|
+
const entries = readMapEntries(fm, key);
|
|
186
234
|
if (entries === null) {
|
|
187
|
-
throw new Error(`${ctx}:
|
|
235
|
+
throw new Error(`${ctx}: ${key} must be a map of shortcode → standing`);
|
|
188
236
|
}
|
|
189
237
|
const out = {};
|
|
190
238
|
for (const [code, value] of entries) {
|
|
191
239
|
const standing = String(value);
|
|
192
240
|
if (!AFFILIATION_STANDINGS.includes(standing)) {
|
|
193
241
|
throw new Error(
|
|
194
|
-
`${ctx}:
|
|
242
|
+
`${ctx}: ${key}["${code}"] must be one of ${AFFILIATION_STANDINGS.join(", ")}, got "${value}"`,
|
|
195
243
|
);
|
|
196
244
|
}
|
|
197
245
|
out[code] = standing;
|
|
@@ -216,7 +264,7 @@ export function resolveRelation(fm, ctx = "item") {
|
|
|
216
264
|
* @throws {Error} When `subType` is missing or blank.
|
|
217
265
|
*/
|
|
218
266
|
export function requireSubType(fm, ctx) {
|
|
219
|
-
const subType =
|
|
267
|
+
const subType = sohlSystemField(fm, "subType", undefined);
|
|
220
268
|
if (subType == null || subType === "") {
|
|
221
269
|
const label = ctx || fm?.title || fm?.name || "item";
|
|
222
270
|
throw new Error(
|
|
@@ -251,3 +299,35 @@ export function parseValueDesc(raw) {
|
|
|
251
299
|
return { label: String(entry), maxValue: 0 };
|
|
252
300
|
});
|
|
253
301
|
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The compendium folder a note names, and how it named it.
|
|
305
|
+
*
|
|
306
|
+
* Two spellings, deliberately not merged into one value: `packFolder:` is a
|
|
307
|
+
* folder note's **address** (`folder-poisonsandtoxins`) and `folder:` is a
|
|
308
|
+
* Foundry **id** (`ONXsqZAIZr2qzxTb`). Which one a value is cannot be told from
|
|
309
|
+
* the string — both are alphanumeric — so the field it was written in is what
|
|
310
|
+
* says, and that answer is carried rather than re-derived (#251).
|
|
311
|
+
*
|
|
312
|
+
* **`packFolder` was a path for one release** (`Possessions/Misc_Gear/Cooking`)
|
|
313
|
+
* and is an address now (#255). A path encoded the hierarchy *in the value*, so
|
|
314
|
+
* reparenting a folder made every note naming it wrong — a structural edit
|
|
315
|
+
* became a corpus-wide rewrite. An address is stable under reparenting, which
|
|
316
|
+
* is exactly why a note is addressed by `(type, shortcode)` and never by
|
|
317
|
+
* `file.path`. The path form is **removed rather than deprecated**: it had no
|
|
318
|
+
* authors to migrate, which is the whole reason the change was cheap enough to
|
|
319
|
+
* make.
|
|
320
|
+
*
|
|
321
|
+
* `packFolder` wins where both are present. Nothing about `folder` changes: a
|
|
322
|
+
* note that names one is read, resolved and emitted exactly as before, until
|
|
323
|
+
* #260 retires it.
|
|
324
|
+
*
|
|
325
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
326
|
+
* @returns {{value: string|null, isAddress: boolean}} The authored value, and
|
|
327
|
+
* whether it is a folder note's address.
|
|
328
|
+
*/
|
|
329
|
+
export function folderField(fm) {
|
|
330
|
+
const asAddress = sohlField(fm, "packFolder", null);
|
|
331
|
+
if (asAddress != null && asAddress !== "") return { value: asAddress, isAddress: true };
|
|
332
|
+
return { value: sohlField(fm, "folder", null), isAddress: false };
|
|
333
|
+
}
|