@heroiclands/package-build 22.17.0 → 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 +23 -0
- package/bin/content-commands.mjs +114 -37
- package/config.mjs +54 -3
- package/docs/api.md +4 -14
- package/docs/commands.md +89 -52
- package/docs/configuration.md +49 -22
- 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/readability-lint.mjs +196 -18
- package/engine/subtype-registry.mjs +2 -6
- package/engine/systems.mjs +11 -13
- package/package.json +5 -1
- package/types/config.d.mts +14 -2
- 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/readability-lint.d.mts +11 -5
- 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,
|