@heroiclands/package-build 22.17.1 → 22.17.2
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 +14 -0
- package/bin/content-commands.mjs +21 -23
- package/docs/api.md +4 -14
- package/docs/commands.md +19 -18
- package/docs/reference/format-details.md +27 -45
- package/docs/reference/note-types.md +1 -1
- package/engine/actor-compiler.mjs +5 -5
- package/engine/address-diff.mjs +32 -279
- package/engine/art-fields.mjs +8 -5
- package/engine/content-format.yaml +4 -4
- package/engine/content-index.mjs +28 -3
- package/engine/content-lint.mjs +3 -182
- package/engine/frontmatter-lint.mjs +112 -59
- package/engine/helpers.mjs +15 -3
- package/engine/index.mjs +0 -3
- package/engine/map-graphviz.mjs +4 -1
- package/engine/note-frontmatter.mjs +1 -0
- package/engine/note-vocabulary.mjs +1 -13
- package/engine/subtype-registry.mjs +2 -6
- package/engine/systems.mjs +11 -13
- package/package.json +1 -1
- package/types/engine/actor-compiler.d.mts +5 -5
- package/types/engine/address-diff.d.mts +18 -107
- package/types/engine/art-fields.d.mts +4 -5
- package/types/engine/frontmatter-lint.d.mts +14 -0
- package/types/engine/helpers.d.mts +12 -1
- package/types/engine/index.d.mts +0 -1
- package/types/engine/note-vocabulary.d.mts +0 -9
- package/types/engine/subtype-registry.d.mts +2 -6
- package/types/engine/systems.d.mts +6 -6
- package/engine/note-renames.mjs +0 -134
- package/types/engine/note-renames.d.mts +0 -102
package/engine/content-lint.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* disagree without anything detecting it, which the canonical-separator
|
|
23
23
|
* handling already did once on each side.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
25
|
+
* Four rules, all about a note's identity:
|
|
26
26
|
*
|
|
27
27
|
* 1. **Shape** — a `shortcode` is strictly lowercase ASCII-alphanumeric. It is
|
|
28
28
|
* the identity key referenced from saved world data, and it is half of the
|
|
@@ -34,12 +34,7 @@
|
|
|
34
34
|
* reason the other two do: it is a statement about which note holds which
|
|
35
35
|
* address, it needs no `site:` configuration to decide, and a package with
|
|
36
36
|
* no front page is misconfigured whether or not anyone runs a site build.
|
|
37
|
-
* 4. **
|
|
38
|
-
* once held and nothing holds now. It is the same statement as
|
|
39
|
-
* rule 2 read backwards, and it needs the same whole-tree view: an entry can
|
|
40
|
-
* only be checked against every *other* note's address, and two notes
|
|
41
|
-
* claiming one predecessor is the uniqueness rule applied to the past.
|
|
42
|
-
* 5. **The vocabulary a package's kind leaves it** — a package compiling no
|
|
37
|
+
* 4. **The vocabulary a package's kind leaves it** — a package compiling no
|
|
43
38
|
* Foundry documents publishes `doc` and `homepage` notes and nothing else.
|
|
44
39
|
* It is here rather than with the claim check in `note-claims.mjs` because
|
|
45
40
|
* that check runs at compile, and this is the case where no compile runs.
|
|
@@ -72,7 +67,6 @@ import { assertStatedScope } from "./helpers.mjs";
|
|
|
72
67
|
// The corpus, read from the one pass that derives it.
|
|
73
68
|
import { authoredFrontmatter, indexRecordsFor, isNoteRecord, noteFile } from "./content-index.mjs";
|
|
74
69
|
import { checkHomepageCount, isHomepage } from "./homepage.mjs";
|
|
75
|
-
import { declaresRenamedFrom, renamedFrom, renamedFromEntries } from "./note-renames.mjs";
|
|
76
70
|
|
|
77
71
|
/**
|
|
78
72
|
* The shape every `shortcode` must match: lowercase ASCII letters and digits
|
|
@@ -175,110 +169,6 @@ function collectNotes(contentBase, { skipDirectories, config, records, problems
|
|
|
175
169
|
return notes;
|
|
176
170
|
}
|
|
177
171
|
|
|
178
|
-
/**
|
|
179
|
-
* What one note's `renamedFrom:` says, checked against itself.
|
|
180
|
-
*
|
|
181
|
-
* The entries a note can be wrong about on its own: a value that is not a
|
|
182
|
-
* shortcode, one naming the address the note holds *now*, one written twice.
|
|
183
|
-
* Every one of them is silent without a check — a malformed entry is skipped by
|
|
184
|
-
* the diff, so the author who wrote it sees the rename they were trying to
|
|
185
|
-
* announce reported as a withdrawal anyway, with nothing saying why.
|
|
186
|
-
*
|
|
187
|
-
* The cross-note questions are not here, because one note cannot answer them:
|
|
188
|
-
* whether an entry names an address some *other* note still publishes, and
|
|
189
|
-
* whether two notes claim one predecessor, both need the whole tree and are
|
|
190
|
-
* asked in {@link lintContentTree} once it has one.
|
|
191
|
-
*
|
|
192
|
-
* @param {object} note - The note, as {@link collectNotes} yields it.
|
|
193
|
-
* @param {() => string} raw - Reads the file, deferred so a clean note costs
|
|
194
|
-
* nothing.
|
|
195
|
-
* @returns {Array<object>} The findings.
|
|
196
|
-
*/
|
|
197
|
-
function checkRenamedFrom({ fm, file }, raw) {
|
|
198
|
-
if (!declaresRenamedFrom(fm)) return [];
|
|
199
|
-
const findings = [];
|
|
200
|
-
const at = (value) => positionInFrontmatter(raw(), "renamedFrom", value);
|
|
201
|
-
const shortcode = typeof fm.shortcode === "string" ? fm.shortcode.trim() : "";
|
|
202
|
-
|
|
203
|
-
// A rename is a statement about where this note's address moved *to*, so a
|
|
204
|
-
// note with no address of its own has made no such statement. Reported
|
|
205
|
-
// before the entries: telling the author their entries are fine would be
|
|
206
|
-
// the less useful half of the answer.
|
|
207
|
-
if (!shortcode) {
|
|
208
|
-
return [
|
|
209
|
-
{
|
|
210
|
-
file,
|
|
211
|
-
...at(undefined),
|
|
212
|
-
severity: "error",
|
|
213
|
-
message:
|
|
214
|
-
"`renamedFrom` names the address this note used to hold, " +
|
|
215
|
-
"but the note declares no `shortcode`, so it holds none now " +
|
|
216
|
-
"and nothing was renamed",
|
|
217
|
-
},
|
|
218
|
-
];
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const seen = new Set();
|
|
222
|
-
for (const entry of renamedFromEntries(fm)) {
|
|
223
|
-
// Not `String(entry)`: the point is that the author wrote something
|
|
224
|
-
// that is not a shortcode, and rendering a list as `a,b` would show
|
|
225
|
-
// them a string they never typed.
|
|
226
|
-
if (typeof entry !== "string" || !entry.trim()) {
|
|
227
|
-
findings.push({
|
|
228
|
-
file,
|
|
229
|
-
...at(undefined),
|
|
230
|
-
severity: "error",
|
|
231
|
-
message:
|
|
232
|
-
`\`renamedFrom\` takes shortcodes, and one entry is ` +
|
|
233
|
-
`${entry === "" || (typeof entry === "string" && !entry.trim()) ? "blank" : `a ${typeof entry}`}; ` +
|
|
234
|
-
`it is skipped, so the rename it was meant to announce is ` +
|
|
235
|
-
`still reported as a withdrawal`,
|
|
236
|
-
});
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
const value = entry.trim();
|
|
240
|
-
if (!isValidShortcode(value)) {
|
|
241
|
-
findings.push({
|
|
242
|
-
file,
|
|
243
|
-
...at(value),
|
|
244
|
-
severity: "error",
|
|
245
|
-
message:
|
|
246
|
-
`\`renamedFrom: ${value}\` is not strictly alphanumeric — ` +
|
|
247
|
-
`lowercase letters and digits only (${ADDRESS_SEGMENT_PATTERN.source}) — ` +
|
|
248
|
-
`so it is not an address this package ever published — a ` +
|
|
249
|
-
`shortcode is held to one charset whether it is current or past`,
|
|
250
|
-
});
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
if (value === shortcode) {
|
|
254
|
-
findings.push({
|
|
255
|
-
file,
|
|
256
|
-
...at(value),
|
|
257
|
-
severity: "error",
|
|
258
|
-
message:
|
|
259
|
-
`\`renamedFrom: ${value}\` is this note's own shortcode, so ` +
|
|
260
|
-
`it declares a rename from itself; name the shortcode it ` +
|
|
261
|
-
`was published under before, or drop the key`,
|
|
262
|
-
});
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
if (seen.has(value)) {
|
|
266
|
-
findings.push({
|
|
267
|
-
file,
|
|
268
|
-
...at(value),
|
|
269
|
-
// The declaration still works — the reader de-duplicates — so
|
|
270
|
-
// this is tidiness, and failing a build over it would red a
|
|
271
|
-
// tree whose renames are all correctly announced.
|
|
272
|
-
severity: "warning",
|
|
273
|
-
message: `\`renamedFrom: ${value}\` is listed twice; the repeat says nothing new`,
|
|
274
|
-
});
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
seen.add(value);
|
|
278
|
-
}
|
|
279
|
-
return findings;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
172
|
/**
|
|
283
173
|
* Lint every address in a content tree.
|
|
284
174
|
*
|
|
@@ -313,9 +203,6 @@ export function lintContentTree(
|
|
|
313
203
|
|
|
314
204
|
/** @type {Map<string, Array<{file: string, absPath: string}>>} */
|
|
315
205
|
const byKey = new Map();
|
|
316
|
-
/** @type {Map<string, Array<{file: string, absPath: string, shortcode: string}>>} */
|
|
317
|
-
const claimedPredecessors = new Map();
|
|
318
|
-
|
|
319
206
|
for (const note of notes) {
|
|
320
207
|
const { fm, absPath, file } = note;
|
|
321
208
|
const shortcode = fm.shortcode;
|
|
@@ -324,7 +211,7 @@ export function lintContentTree(
|
|
|
324
211
|
// tree costs one pass rather than two.
|
|
325
212
|
const raw = () => fs.readFileSync(absPath, "utf8");
|
|
326
213
|
|
|
327
|
-
// Rule
|
|
214
|
+
// Rule 4, and it is the whole of the check for a package that compiles
|
|
328
215
|
// nothing: no pass downstream would report the note, because the pass
|
|
329
216
|
// that reports an unclaimed type is a compile pass and none runs.
|
|
330
217
|
const type = typeof fm.type === "string" ? fm.type.trim() : "";
|
|
@@ -341,25 +228,6 @@ export function lintContentTree(
|
|
|
341
228
|
});
|
|
342
229
|
}
|
|
343
230
|
|
|
344
|
-
// Before the keyless `continue` below, because a note declaring a
|
|
345
|
-
// rename while carrying no address of its own is exactly one of the
|
|
346
|
-
// things this reports — and reaching it after the skip would mean it
|
|
347
|
-
// never ran on the case that needs it most.
|
|
348
|
-
findings.push(...checkRenamedFrom(note, raw));
|
|
349
|
-
if (shortcode) {
|
|
350
|
-
// The de-duplicated reader, not the raw entries: a note that listed
|
|
351
|
-
// one predecessor twice has made one claim, and indexing it twice
|
|
352
|
-
// would make the note collide with itself and be reported as two
|
|
353
|
-
// notes claiming one address.
|
|
354
|
-
for (const value of renamedFrom(fm)) {
|
|
355
|
-
if (value === shortcode) continue;
|
|
356
|
-
const claim = `${fm.type}:${value}`;
|
|
357
|
-
const seen = claimedPredecessors.get(claim);
|
|
358
|
-
if (seen) seen.push({ file, absPath, shortcode });
|
|
359
|
-
else claimedPredecessors.set(claim, [{ file, absPath, shortcode }]);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
231
|
// Folder documents and keyless entries carry no address at all.
|
|
364
232
|
if (!shortcode) continue;
|
|
365
233
|
|
|
@@ -436,52 +304,5 @@ export function lintContentTree(
|
|
|
436
304
|
}
|
|
437
305
|
}
|
|
438
306
|
|
|
439
|
-
// The two questions about a declared rename that need the whole tree.
|
|
440
|
-
// Both are the uniqueness rule above, applied to the past: an
|
|
441
|
-
// address has one holder, so it has one successor and it cannot be both
|
|
442
|
-
// vacated and occupied.
|
|
443
|
-
for (const [claim, claimants] of claimedPredecessors) {
|
|
444
|
-
const live = byKey.get(claim);
|
|
445
|
-
if (live) {
|
|
446
|
-
for (const { file, absPath } of claimants) {
|
|
447
|
-
findings.push({
|
|
448
|
-
file,
|
|
449
|
-
...positionInFrontmatter(
|
|
450
|
-
fs.readFileSync(absPath, "utf8"),
|
|
451
|
-
"renamedFrom",
|
|
452
|
-
claim.slice(claim.indexOf(":") + 1),
|
|
453
|
-
),
|
|
454
|
-
severity: "error",
|
|
455
|
-
message:
|
|
456
|
-
`\`renamedFrom\` claims "${claim}", which ` +
|
|
457
|
-
`${live.map((f) => f.file).join(", ")} still publishes; ` +
|
|
458
|
-
`that address was never vacated, so nothing was renamed ` +
|
|
459
|
-
`away from it`,
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
if (claimants.length < 2) continue;
|
|
464
|
-
// Named on every claimant rather than once on the address, for the same
|
|
465
|
-
// reason a duplicate address is: each is a file an author has to open,
|
|
466
|
-
// and a finding naming only the address sends them hunting for the rest.
|
|
467
|
-
for (const { file, absPath, shortcode } of claimants) {
|
|
468
|
-
const others = claimants.filter((c) => c.file !== file);
|
|
469
|
-
findings.push({
|
|
470
|
-
file,
|
|
471
|
-
...positionInFrontmatter(
|
|
472
|
-
fs.readFileSync(absPath, "utf8"),
|
|
473
|
-
"renamedFrom",
|
|
474
|
-
claim.slice(claim.indexOf(":") + 1),
|
|
475
|
-
),
|
|
476
|
-
severity: "error",
|
|
477
|
-
message:
|
|
478
|
-
`"${claim}" is claimed as a predecessor by more than one ` +
|
|
479
|
-
`note — this one (now "${shortcode}") and ` +
|
|
480
|
-
`${others.map((c) => `${c.file} (now "${c.shortcode}")`).join(", ")}; ` +
|
|
481
|
-
`an address had one holder, so it has one successor`,
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
307
|
return { findings, notes: notes.length, keys: byKey.size };
|
|
487
308
|
}
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
import { isAddressTuple } from "./address.mjs";
|
|
58
58
|
import { authoredNoteKeys, NOTE_TOP_LEVEL_KEY_SET } from "./note-frontmatter.mjs";
|
|
59
59
|
import { AddressEntries } from "./address-values.mjs";
|
|
60
|
+
import { addressPositions } from "./note-addresses.mjs";
|
|
60
61
|
import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
|
|
61
62
|
import {
|
|
62
63
|
legacyKeyOf,
|
|
@@ -79,7 +80,6 @@ import { DEFAULT_PARENT } from "./folder-notes.mjs";
|
|
|
79
80
|
import {
|
|
80
81
|
dataFields,
|
|
81
82
|
declaredTags,
|
|
82
|
-
exclusiveTagGroups,
|
|
83
83
|
subTypeCharsetMessage,
|
|
84
84
|
typeCharsetMessage,
|
|
85
85
|
} from "./note-vocabulary.mjs";
|
|
@@ -597,6 +597,115 @@ function checkDataReferences(note, field, value, segments, context, index) {
|
|
|
597
597
|
return findings;
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
+
/**
|
|
601
|
+
* Read values at a schema-declared address path, expanding wildcard segments.
|
|
602
|
+
* @param {unknown} value - The value at the current path.
|
|
603
|
+
* @param {readonly string[]} path - Remaining declaration path.
|
|
604
|
+
* @param {readonly (string|number)[]} [actual] - Authored path so far.
|
|
605
|
+
* @returns {Array<{value: unknown, path: Array<string|number>}>} Values and their paths.
|
|
606
|
+
*/
|
|
607
|
+
function valuesAtAddressPath(value, path, actual = []) {
|
|
608
|
+
if (!path.length) return [{ value, path: [...actual] }];
|
|
609
|
+
const [head, ...tail] = path;
|
|
610
|
+
if (head === "*") {
|
|
611
|
+
const entries =
|
|
612
|
+
Array.isArray(value) ? value.map((entry, index) => [index, entry])
|
|
613
|
+
: value && typeof value === "object" && !isAddressTuple(value) ? Object.entries(value)
|
|
614
|
+
: [];
|
|
615
|
+
return entries.flatMap(([key, entry]) =>
|
|
616
|
+
valuesAtAddressPath(entry, tail, [...actual, key]),
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
if (!value || typeof value !== "object" || isAddressTuple(value)) return [];
|
|
620
|
+
return valuesAtAddressPath(value[head], tail, [...actual, head]);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Resolve system-block addresses declared by the system's own field schema.
|
|
625
|
+
* @param {object} note - A note from the link index.
|
|
626
|
+
* @param {object} opts
|
|
627
|
+
* @param {object} opts.index - The address index.
|
|
628
|
+
* @param {Record<string, readonly object[]>} [opts.schemas] - Note schemas.
|
|
629
|
+
* @param {Readonly<Record<string, object>>} [opts.systems] - Declared system blocks.
|
|
630
|
+
* @returns {object[]} Located findings for unresolved addresses.
|
|
631
|
+
*/
|
|
632
|
+
export function systemAddressFindings(note, { index, schemas, systems } = {}) {
|
|
633
|
+
if (!index?.addressHit) return [];
|
|
634
|
+
const findings = [];
|
|
635
|
+
const fm = note.fm ?? {};
|
|
636
|
+
for (const position of addressPositions(fm, { schemas, systemBlocks: systems })) {
|
|
637
|
+
const block = position.path[0];
|
|
638
|
+
if (!Object.hasOwn(systems ?? {}, block) || position.path[1] === "packFolder") continue;
|
|
639
|
+
const candidates = valuesAtAddressPath(fm, position.path);
|
|
640
|
+
for (const candidate of candidates) {
|
|
641
|
+
let values;
|
|
642
|
+
if (position.shape === "list") {
|
|
643
|
+
values =
|
|
644
|
+
Array.isArray(candidate.value) ?
|
|
645
|
+
candidate.value.map((value, index) => ({
|
|
646
|
+
value,
|
|
647
|
+
path: [...candidate.path, index],
|
|
648
|
+
}))
|
|
649
|
+
: [];
|
|
650
|
+
} else if (position.shape === "keys") {
|
|
651
|
+
values =
|
|
652
|
+
candidate.value instanceof AddressEntries ?
|
|
653
|
+
candidate.value.entries.map((entry) => ({
|
|
654
|
+
value: entry.target,
|
|
655
|
+
path: [...candidate.path, entry.sourceKey],
|
|
656
|
+
key: true,
|
|
657
|
+
}))
|
|
658
|
+
: candidate.value && typeof candidate.value === "object" ?
|
|
659
|
+
Object.keys(candidate.value).map((key) => ({
|
|
660
|
+
value: key,
|
|
661
|
+
path: [...candidate.path, key],
|
|
662
|
+
key: true,
|
|
663
|
+
}))
|
|
664
|
+
: [];
|
|
665
|
+
} else if (position.shape === "scalar-or-map" && mapEntries(candidate.value)) {
|
|
666
|
+
values = Object.entries(mapEntries(candidate.value)).map(([key, value]) => ({
|
|
667
|
+
value,
|
|
668
|
+
path: [...candidate.path, key],
|
|
669
|
+
}));
|
|
670
|
+
} else {
|
|
671
|
+
values = [candidate];
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
for (const item of values) {
|
|
675
|
+
if (item.value == null || item.value === "") continue;
|
|
676
|
+
const context = {
|
|
677
|
+
package: index.contentPackage,
|
|
678
|
+
system: position.system ?? block,
|
|
679
|
+
type: position.type,
|
|
680
|
+
types: index.types,
|
|
681
|
+
};
|
|
682
|
+
const address =
|
|
683
|
+
isAddressTuple(item.value) ? item.value
|
|
684
|
+
: typeof item.value === "string" ?
|
|
685
|
+
parseAddress(item.value, context, {
|
|
686
|
+
declared: true,
|
|
687
|
+
legacyShortcodeCase: position.legacyShortcodeCase,
|
|
688
|
+
})
|
|
689
|
+
: null;
|
|
690
|
+
if (!address || address.reason) continue;
|
|
691
|
+
if (position.accepts && !acceptsType(address, position.accepts)) continue;
|
|
692
|
+
const target = renderAddress(address);
|
|
693
|
+
if (index.addressHit(target)) continue;
|
|
694
|
+
const path = item.path;
|
|
695
|
+
findings.push({
|
|
696
|
+
file: note.file,
|
|
697
|
+
...positionOfFrontmatterPath(note.raw ?? "", path, { key: item.key }),
|
|
698
|
+
severity: "error",
|
|
699
|
+
message:
|
|
700
|
+
`\`${path.map(String).join(".")}\` names ${target}, which does not ` +
|
|
701
|
+
`resolve in this package or its declared dependencies`,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return findings;
|
|
707
|
+
}
|
|
708
|
+
|
|
600
709
|
/**
|
|
601
710
|
* Check one `scalar-or-map` field written in its map form, entry by entry.
|
|
602
711
|
*
|
|
@@ -774,14 +883,6 @@ function checkSubType(note, { type, entry }) {
|
|
|
774
883
|
* findings above become none while a settlement tagged `vilage` is still
|
|
775
884
|
* caught.
|
|
776
885
|
*
|
|
777
|
-
* **A group declared as a slot is checked twice over.** Its tags are the
|
|
778
|
-
* alternative answers to one question — a being is a `character` or a
|
|
779
|
-
* `creature` — so two of them on one note is refused outright, by
|
|
780
|
-
* {@link checkExclusiveTags}, on top of the near miss every group gets. An
|
|
781
|
-
* *unfilled* slot is not a finding: the kind is authored deliberately, and
|
|
782
|
-
* nothing can tell a being nobody has classified yet from one the author means
|
|
783
|
-
* to leave unclassified.
|
|
784
|
-
*
|
|
785
886
|
* @param {object} note - The note.
|
|
786
887
|
* @param {object} opts
|
|
787
888
|
* @param {string} opts.type - The note's declared `type`, which scopes the
|
|
@@ -816,55 +917,6 @@ function checkTags(note, { type }) {
|
|
|
816
917
|
return findings;
|
|
817
918
|
}
|
|
818
919
|
|
|
819
|
-
/**
|
|
820
|
-
* Refuse a note that fills one single-valued tag slot twice.
|
|
821
|
-
*
|
|
822
|
-
* This is the whole of what "a closed tag vocabulary" can mean while `tags:`
|
|
823
|
-
* stays open. A slot's values are alternatives, not attributes: a being is a
|
|
824
|
-
* `character` or a `creature`, so a note carrying both has answered the
|
|
825
|
-
* question twice and every reader of the tag — an index, a query, a renderer —
|
|
826
|
-
* gets to pick. That is the same silent wrongness a misspelt classifying tag
|
|
827
|
-
* is, and it gets the same answer.
|
|
828
|
-
*
|
|
829
|
-
* **An error, not a warning**, because a warning does not change the build's
|
|
830
|
-
* exit code and a contradiction that still ships is a contradiction nobody
|
|
831
|
-
* fixes.
|
|
832
|
-
*
|
|
833
|
-
* **Located on the `tags` key**, because the fault is the list rather than
|
|
834
|
-
* either entry in it — both values are correctly spelt, and pointing at one of
|
|
835
|
-
* them would say the wrong one is the wrong one.
|
|
836
|
-
*
|
|
837
|
-
* @param {object} note - The note.
|
|
838
|
-
* @param {object} opts
|
|
839
|
-
* @param {string} opts.type - The note's declared `type`, which scopes the
|
|
840
|
-
* slots checked.
|
|
841
|
-
* @returns {object[]} Findings.
|
|
842
|
-
*/
|
|
843
|
-
function checkExclusiveTags(note, { type }) {
|
|
844
|
-
const authored = (note.fm ?? {}).tags;
|
|
845
|
-
if (!Array.isArray(authored)) return [];
|
|
846
|
-
|
|
847
|
-
const carried = new Set(
|
|
848
|
-
authored.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim()),
|
|
849
|
-
);
|
|
850
|
-
const findings = [];
|
|
851
|
-
for (const { slot, tags } of exclusiveTagGroups(type)) {
|
|
852
|
-
const filled = tags.filter((t) => carried.has(t));
|
|
853
|
-
if (filled.length < 2) continue;
|
|
854
|
-
findings.push({
|
|
855
|
-
file: note.file,
|
|
856
|
-
...positionInFrontmatter(note.raw ?? "", "tags"),
|
|
857
|
-
severity: "error",
|
|
858
|
-
message:
|
|
859
|
-
`a ${type}'s ${slot} is one tag, and this note carries ` +
|
|
860
|
-
`${filled.map((t) => `"${t}"`).join(" and ")}. Those are the alternative ` +
|
|
861
|
-
`answers to one question (${tags.join(", ")}), so carrying two of them ` +
|
|
862
|
-
`states no ${slot} at all. Keep the one that is true, or neither`,
|
|
863
|
-
});
|
|
864
|
-
}
|
|
865
|
-
return findings;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
920
|
/**
|
|
869
921
|
* The frontmatter fields that name artwork.
|
|
870
922
|
*
|
|
@@ -1421,7 +1473,6 @@ export function lintNote(
|
|
|
1421
1473
|
// type's property — `draft` belongs to any note and `village` to a place —
|
|
1422
1474
|
// so the finding must survive the early returns below.
|
|
1423
1475
|
findings.push(...checkTags(note, { type }));
|
|
1424
|
-
findings.push(...checkExclusiveTags(note, { type }));
|
|
1425
1476
|
|
|
1426
1477
|
// A refused field must be one the note *wrote*: `resolveNoteId` fills
|
|
1427
1478
|
// `fm.id` in place, so the parsed frontmatter carries a derived id the
|
|
@@ -1753,6 +1804,8 @@ export function lintNote(
|
|
|
1753
1804
|
}
|
|
1754
1805
|
}
|
|
1755
1806
|
|
|
1807
|
+
findings.push(...systemAddressFindings(note, { index, schemas, systems }));
|
|
1808
|
+
|
|
1756
1809
|
return findings;
|
|
1757
1810
|
}
|
|
1758
1811
|
|
package/engine/helpers.mjs
CHANGED
|
@@ -177,7 +177,8 @@ export function renderFoundryMarkdown(body, captions, footnoteNumbers, docId) {
|
|
|
177
177
|
* @param {string} filePath - Markdown file to read.
|
|
178
178
|
* @param {{addressContext?: object}} [opts] - Address defaults for decoded fields.
|
|
179
179
|
* @returns {{frontmatter: object|null, body: string, description: string,
|
|
180
|
-
* bodyLine?: number, bodyColumn?: number
|
|
180
|
+
* bodyLine?: number, bodyColumn?: number, parseError?: {message: string,
|
|
181
|
+
* line?: number, column?: number}}} Parsed note and body position.
|
|
181
182
|
*/
|
|
182
183
|
export function parseMarkdownFile(filePath, { addressContext } = {}) {
|
|
183
184
|
const content = fs.readFileSync(filePath, "utf8");
|
|
@@ -189,8 +190,19 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
|
|
|
189
190
|
try {
|
|
190
191
|
frontmatter = yaml.parse(fmMatch[1]) || {};
|
|
191
192
|
} catch (err) {
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
const firstLine = String(err.message).split("\n", 1)[0];
|
|
194
|
+
const message = firstLine.replace(/ at line \d+, column \d+.*$/, "");
|
|
195
|
+
const location = err.linePos?.[0];
|
|
196
|
+
return {
|
|
197
|
+
frontmatter: null,
|
|
198
|
+
body: "",
|
|
199
|
+
description: "",
|
|
200
|
+
parseError: {
|
|
201
|
+
message: `YAML parse error: ${message}`,
|
|
202
|
+
...(Number.isInteger(location?.line) ? { line: location.line + 1 } : {}),
|
|
203
|
+
...(Number.isInteger(location?.col) ? { column: location.col } : {}),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
194
206
|
}
|
|
195
207
|
if (addressContext) {
|
|
196
208
|
try {
|
package/engine/index.mjs
CHANGED
|
@@ -178,9 +178,6 @@ export * as infoboxRender from "./infobox-render.mjs";
|
|
|
178
178
|
/** The id a note's document is filed under: its pin, or its address. */
|
|
179
179
|
export * as noteIds from "./note-ids.mjs";
|
|
180
180
|
|
|
181
|
-
/** The shortcodes a note declares it used to be published under. */
|
|
182
|
-
export * as noteRenames from "./note-renames.mjs";
|
|
183
|
-
|
|
184
181
|
/** The published content index packages exchange addresses through. */
|
|
185
182
|
export * as metadataIndex from "./metadata-index.mjs";
|
|
186
183
|
|
package/engine/map-graphviz.mjs
CHANGED
|
@@ -55,7 +55,10 @@ export function renderDot(dotPath, outPath, { engine, format = "svg", nop, dpi =
|
|
|
55
55
|
.filter(
|
|
56
56
|
(error) =>
|
|
57
57
|
error.level === "warning" &&
|
|
58
|
-
!error.message.includes("no hard-coded metrics for 'Helvetica-Bold'")
|
|
58
|
+
!error.message.includes("no hard-coded metrics for 'Helvetica-Bold'") &&
|
|
59
|
+
!/^Warning: no value for width of non-ASCII character \d+\. Falling back to width of space character$/.test(
|
|
60
|
+
error.message,
|
|
61
|
+
),
|
|
59
62
|
)
|
|
60
63
|
.map((error) => error.message)
|
|
61
64
|
.join("; "),
|
|
@@ -571,15 +571,6 @@ export const DECLARED_TAGS = Object.freeze({
|
|
|
571
571
|
"unguilded",
|
|
572
572
|
]),
|
|
573
573
|
}),
|
|
574
|
-
/**
|
|
575
|
-
* What kind of being this is — a person, or one of the beasts and made
|
|
576
|
-
* things. A being is one or the other, so the group is a slot.
|
|
577
|
-
*/
|
|
578
|
-
beingKind: Object.freeze({
|
|
579
|
-
types: ["being"],
|
|
580
|
-
exclusive: "kind",
|
|
581
|
-
tags: Object.freeze(["character", "creature"]),
|
|
582
|
-
}),
|
|
583
574
|
/** A note's working state, which any note may carry. */
|
|
584
575
|
state: Object.freeze({ types: null, tags: Object.freeze([DRAFT_TAG]) }),
|
|
585
576
|
/** Who may read a completed note. */
|
|
@@ -692,10 +683,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
692
683
|
|
|
693
684
|
being: Object.freeze({
|
|
694
685
|
stubbable: true,
|
|
695
|
-
|
|
696
|
-
// lands with. Declared open until it does, because inventing the
|
|
697
|
-
// values here would put a second, weaker answer beside the real one.
|
|
698
|
-
subTypes: null,
|
|
686
|
+
subTypes: Object.freeze(["npc", "character", "creature"]),
|
|
699
687
|
// Whether an authored `age` disagrees with what `born` and the
|
|
700
688
|
// package's declared present compute — see `engine/being-age.mjs`.
|
|
701
689
|
check: checkBeingAge,
|
|
@@ -43,10 +43,6 @@ import { HM3_DOCUMENT_SUBTYPES } from "../hm3/document-subtypes.mjs";
|
|
|
43
43
|
/**
|
|
44
44
|
* The note-type → document-subtype maps this toolchain ships.
|
|
45
45
|
*
|
|
46
|
-
* Two, since `hm3/` landed — and it joined this list rather than the
|
|
47
|
-
* claim table growing a second copy of the same fact, which is what the list
|
|
48
|
-
* was for.
|
|
49
|
-
*
|
|
50
46
|
* The union is what makes the vocabulary wider than any one repository's
|
|
51
47
|
* configuration: `armorlocation` is a real content type because HM3 maps it,
|
|
52
48
|
* however a given repository is configured, so a tree full of them is a
|
|
@@ -90,7 +86,7 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
|
|
|
90
86
|
export const DEFAULT_DOCUMENT_SUBTYPES = SOHL_DOCUMENT_SUBTYPES;
|
|
91
87
|
|
|
92
88
|
/**
|
|
93
|
-
* Every system
|
|
89
|
+
* Every system identifier with declared system-specific document types.
|
|
94
90
|
*
|
|
95
91
|
* Derived from {@link KNOWN_DOCUMENT_SUBTYPE_MAPS}, so registering a map is the
|
|
96
92
|
* whole of adding a system to every reader that asks "which systems are there?"
|
|
@@ -156,7 +152,7 @@ export const ACTOR_TYPES = Object.freeze(
|
|
|
156
152
|
/**
|
|
157
153
|
* The map one system ships, by its id.
|
|
158
154
|
*
|
|
159
|
-
* @param {string|undefined} system - The system id
|
|
155
|
+
* @param {string|undefined} system - The system id.
|
|
160
156
|
* @returns {import("./document-subtypes.mjs").DocumentSubtypeMap|undefined} Its
|
|
161
157
|
* map, or `undefined` where this toolchain ships none for it.
|
|
162
158
|
*/
|
package/engine/systems.mjs
CHANGED
|
@@ -20,10 +20,8 @@
|
|
|
20
20
|
* address grammar asks on every note, and the issue's answer is that it comes
|
|
21
21
|
* from a **registry rather than from a hardcoded set**: known system ids are
|
|
22
22
|
* declared here, once, an unknown value is an error, and _adding a system is a
|
|
23
|
-
* data change_.
|
|
24
|
-
*
|
|
25
|
-
* per document-subtype map, a system-keyed table per compiler — none of which
|
|
26
|
-
* could refuse a value none of the others had heard of.
|
|
23
|
+
* data change_. The same fact is read by address parsing, configuration and
|
|
24
|
+
* linting.
|
|
27
25
|
*
|
|
28
26
|
* **`none` is a word, deliberately.** It is not a YAML null (`null`, `~`, or an
|
|
29
27
|
* empty value), because those spell "nobody filled this in", and a note that
|
|
@@ -38,8 +36,8 @@
|
|
|
38
36
|
* the configuration loader, by a compiler, and by a lint without closing a
|
|
39
37
|
* cycle around any of them. In particular the ids are *declared* here rather
|
|
40
38
|
* than derived from `KNOWN_DOCUMENT_SUBTYPE_MAPS` — deriving them would drag
|
|
41
|
-
*
|
|
42
|
-
*
|
|
39
|
+
* system-specific modules behind every import of this file. `tests/systems.test.ts`
|
|
40
|
+
* holds the registry and note-key vocabulary in step, which is where
|
|
43
41
|
* the agreement between a registry and its implementations belongs.
|
|
44
42
|
*
|
|
45
43
|
* @module
|
|
@@ -66,11 +64,11 @@ export const NO_SYSTEM = "none";
|
|
|
66
64
|
export const NOTE_SYSTEM = "note";
|
|
67
65
|
|
|
68
66
|
/**
|
|
69
|
-
* Every game system this toolchain
|
|
67
|
+
* Every game system identifier this toolchain recognizes.
|
|
70
68
|
*
|
|
71
|
-
* The registry, and the only place the list is written down.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
69
|
+
* The registry, and the only place the list is written down. A recognized id
|
|
70
|
+
* can have no system-specific document types; adding one of those requires a
|
|
71
|
+
* subtype map and compiler as well.
|
|
74
72
|
*
|
|
75
73
|
* `none` is **not** a member: it marks the absence of a system, and a caller
|
|
76
74
|
* asking "is this a system" and a caller asking "is this a well-formed
|
|
@@ -79,7 +77,7 @@ export const NOTE_SYSTEM = "note";
|
|
|
79
77
|
*
|
|
80
78
|
* @type {ReadonlySet<string>}
|
|
81
79
|
*/
|
|
82
|
-
export const SYSTEM_IDS = Object.freeze(new Set(["sohl", "hm3"]));
|
|
80
|
+
export const SYSTEM_IDS = Object.freeze(new Set(["sohl", "hm3", "dnd5e"]));
|
|
83
81
|
|
|
84
82
|
/**
|
|
85
83
|
* Everything the `<system>` segment of an address may say.
|
|
@@ -127,8 +125,8 @@ export function isSystemSegment(value) {
|
|
|
127
125
|
*
|
|
128
126
|
* The nullish case is called out by name because it is the likely mistake and
|
|
129
127
|
* the least legible failure: a YAML `null`, a `~`, or a key written with no
|
|
130
|
-
* value at all reaches here as `undefined`, and
|
|
131
|
-
*
|
|
128
|
+
* value at all reaches here as `undefined`, and a list of accepted values
|
|
129
|
+
* would leave a reader hunting for the value they cannot see.
|
|
132
130
|
*
|
|
133
131
|
* @param {unknown} value - The offending value.
|
|
134
132
|
* @param {string} [where] - What carried it, for the message — a note path, a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "22.17.
|
|
3
|
+
"version": "22.17.2",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -230,11 +230,11 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
230
230
|
* The actor's two pieces of art, resolved, with the being default beneath
|
|
231
231
|
* them.
|
|
232
232
|
*
|
|
233
|
-
* **The default
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
233
|
+
* **The default follows the note's `subType`**, with tags as a compatibility
|
|
234
|
+
* fallback for content trees that have not migrated. Both addresses belong
|
|
235
|
+
* to the package that ships them. A tree whose index cannot answer an
|
|
236
|
+
* address — a dependency not yet fetched — falls through to the document
|
|
237
|
+
* subtype's own default, so the document is never left with no art.
|
|
238
238
|
*
|
|
239
239
|
* `tokenIcon` unset follows `icon`, and the fallback is applied after
|
|
240
240
|
* resolution rather than before: a note naming an icon and no token icon
|