@heroiclands/package-build 8.1.0 → 10.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 +748 -0
- package/CONTENT.md +273 -13
- package/bin/content-build.mjs +479 -123
- package/bin/package-build.mjs +27 -69
- package/bin/report.mjs +1 -2
- package/bundle.mjs +2 -10
- package/config.mjs +31 -106
- package/container.mjs +13 -57
- package/content-config.mjs +300 -188
- package/coverage.mjs +14 -55
- package/deploy.mjs +4 -13
- package/docs/content-format.md +1418 -0
- package/e2e.mjs +16 -55
- package/engine/address-charset.mjs +62 -0
- package/engine/address-diff.mjs +1 -4
- package/engine/alias-index.mjs +153 -0
- package/engine/base-compiler.mjs +203 -31
- package/engine/code-fences.mjs +4 -13
- package/engine/compendiums.mjs +13 -37
- package/engine/content-address.mjs +6 -10
- package/engine/content-format-check.mjs +570 -0
- package/engine/content-format.mjs +253 -0
- package/engine/content-links.mjs +144 -99
- package/engine/content-lint.mjs +12 -16
- package/engine/content-slug.mjs +2 -6
- package/engine/content-tables.mjs +26 -79
- package/engine/diagnostics.mjs +37 -15
- package/engine/document-subtypes.mjs +440 -0
- package/engine/field-reference.mjs +6 -20
- package/engine/field-spec.mjs +49 -45
- package/engine/foreign-catalog.mjs +7 -22
- package/engine/foreign-manifests.mjs +1 -4
- package/engine/frontmatter-lint.mjs +347 -43
- package/engine/frontmatter.mjs +3 -8
- package/engine/generate.mjs +36 -20
- package/engine/helpers.mjs +54 -81
- package/engine/homepage.mjs +4 -15
- package/engine/ids.mjs +21 -12
- package/engine/index.mjs +15 -0
- package/engine/item-registry.mjs +72 -9
- package/engine/journals.mjs +4 -14
- package/engine/kb-manifest.mjs +41 -24
- package/engine/macros.mjs +2 -10
- package/engine/manifest-emit.mjs +6 -17
- package/engine/map-notes.mjs +53 -87
- package/engine/note-claims.mjs +383 -0
- package/engine/note-package.mjs +1 -4
- package/engine/note-vocabulary.mjs +678 -0
- package/engine/pack-config.mjs +56 -60
- package/engine/pack-router.mjs +18 -8
- package/engine/prose-config.mjs +20 -4
- package/engine/prose-lint.mjs +61 -17
- package/engine/region-events.mjs +1 -3
- package/engine/retired-fields.mjs +117 -3
- package/engine/scene-levels.mjs +8 -22
- package/engine/scenes.mjs +31 -47
- package/engine/schema-check.mjs +348 -7
- package/engine/schema-extract.mjs +11 -39
- package/engine/site-build.mjs +13 -38
- package/engine/site-index.mjs +40 -35
- package/engine/system-block.mjs +513 -0
- package/engine/web-wikilinks.mjs +115 -92
- package/engine/wikilink-syntax.mjs +30 -0
- package/engine/wikilinks.mjs +147 -183
- package/index.mjs +1 -5
- package/lang.mjs +1 -3
- package/manifest.mjs +10 -37
- package/markdownlint-config.mjs +1 -5
- package/package.json +6 -2
- package/sohl/actors.mjs +251 -68
- package/sohl/being-info.mjs +3 -6
- package/sohl/document-subtypes.mjs +82 -0
- package/sohl/index.mjs +4 -6
- package/sohl/item-builders.mjs +1 -3
- package/sohl/item-fields.mjs +16 -34
- package/sohl/items.mjs +111 -17
- package/sohl/kb-passes.mjs +29 -39
- package/sohl/note-schemas.mjs +11 -7
- package/sohl/skill-base.mjs +7 -23
- package/stage.mjs +3 -13
- package/templates.mjs +4 -15
- package/types/bundle.d.mts +1 -1
- package/types/container.d.mts +2 -2
- package/types/content-config.d.mts +48 -4
- package/types/coverage.d.mts +1 -1
- package/types/e2e.d.mts +4 -4
- package/types/engine/address-charset.d.mts +45 -0
- package/types/engine/alias-index.d.mts +122 -0
- package/types/engine/base-compiler.d.mts +132 -4
- package/types/engine/content-address.d.mts +2 -2
- package/types/engine/content-format-check.d.mts +163 -0
- package/types/engine/content-format.d.mts +101 -0
- package/types/engine/content-links.d.mts +16 -1
- package/types/engine/content-lint.d.mts +6 -0
- package/types/engine/diagnostics.d.mts +29 -0
- package/types/engine/document-subtypes.d.mts +233 -0
- package/types/engine/field-spec.d.mts +76 -23
- package/types/engine/frontmatter-lint.d.mts +47 -2
- package/types/engine/generate.d.mts +15 -2
- package/types/engine/helpers.d.mts +22 -14
- package/types/engine/ids.d.mts +10 -0
- package/types/engine/index.d.mts +5 -0
- package/types/engine/item-registry.d.mts +21 -2
- package/types/engine/kb-manifest.d.mts +35 -8
- package/types/engine/map-notes.d.mts +21 -11
- package/types/engine/note-claims.d.mts +113 -0
- package/types/engine/note-vocabulary.d.mts +251 -0
- package/types/engine/pack-config.d.mts +4 -3
- package/types/engine/pack-router.d.mts +4 -4
- package/types/engine/prose-lint.d.mts +6 -2
- package/types/engine/retired-fields.d.mts +73 -2
- package/types/engine/schema-check.d.mts +182 -0
- package/types/engine/schema-extract.d.mts +1 -1
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/system-block.d.mts +281 -0
- package/types/engine/web-wikilinks.d.mts +23 -12
- package/types/engine/wikilink-syntax.d.mts +29 -0
- package/types/manifest.d.mts +1 -1
- package/types/sohl/actors.d.mts +62 -6
- package/types/sohl/document-subtypes.d.mts +14 -0
- package/types/sohl/index.d.mts +1 -0
- package/types/sohl/items.d.mts +21 -0
|
@@ -203,9 +203,7 @@ export function writeZipEntries(files, dest) {
|
|
|
203
203
|
* @returns {Promise<void>}
|
|
204
204
|
*/
|
|
205
205
|
async function extractItemPacks(id, version, manifest, root, dir) {
|
|
206
|
-
const itemPacks = (manifest.packs ?? []).filter(
|
|
207
|
-
(pack) => pack.type === "Item",
|
|
208
|
-
);
|
|
206
|
+
const itemPacks = (manifest.packs ?? []).filter((pack) => pack.type === "Item");
|
|
209
207
|
if (!itemPacks.length) {
|
|
210
208
|
throw new Error(
|
|
211
209
|
`${id}@${version}: its manifest declares no Item packs, so it ` +
|
|
@@ -239,9 +237,7 @@ async function extractItemPacks(id, version, manifest, root, dir) {
|
|
|
239
237
|
async function downloadAndUnzip(url, dest) {
|
|
240
238
|
const res = await fetch(url, { redirect: "follow" });
|
|
241
239
|
if (!res.ok) {
|
|
242
|
-
throw new Error(
|
|
243
|
-
`could not download ${url}: HTTP ${res.status} ${res.statusText}`,
|
|
244
|
-
);
|
|
240
|
+
throw new Error(`could not download ${url}: HTTP ${res.status} ${res.statusText}`);
|
|
245
241
|
}
|
|
246
242
|
writeZipEntries(unzipSync(new Uint8Array(await res.arrayBuffer())), dest);
|
|
247
243
|
}
|
|
@@ -272,10 +268,7 @@ export function pinnedManifestUrl(url, verified) {
|
|
|
272
268
|
if (at === -1) return { url, pinned: false };
|
|
273
269
|
const tag = verified.startsWith("v") ? verified : `v${verified}`;
|
|
274
270
|
return {
|
|
275
|
-
url:
|
|
276
|
-
url.slice(0, at) +
|
|
277
|
-
`/releases/download/${tag}/` +
|
|
278
|
-
url.slice(at + marker.length),
|
|
271
|
+
url: url.slice(0, at) + `/releases/download/${tag}/` + url.slice(at + marker.length),
|
|
279
272
|
pinned: true,
|
|
280
273
|
};
|
|
281
274
|
}
|
|
@@ -318,9 +311,7 @@ export async function fetchCatalog(config, rel) {
|
|
|
318
311
|
|
|
319
312
|
const download = manifest.download;
|
|
320
313
|
if (!download) {
|
|
321
|
-
throw new Error(
|
|
322
|
-
`${rel.id}@${version}: its manifest declares no \`download\``,
|
|
323
|
-
);
|
|
314
|
+
throw new Error(`${rel.id}@${version}: its manifest declares no \`download\``);
|
|
324
315
|
}
|
|
325
316
|
|
|
326
317
|
// Rebuild from empty: a previous run may have died partway, and a stale
|
|
@@ -398,9 +389,7 @@ export async function fetchCatalogFromPath(config, rel, source) {
|
|
|
398
389
|
throw new Error(`${rel.id}: nothing at ${source}`);
|
|
399
390
|
}
|
|
400
391
|
|
|
401
|
-
const staging = fs.mkdtempSync(
|
|
402
|
-
path.join(os.tmpdir(), `content-build-${rel.id}-`),
|
|
403
|
-
);
|
|
392
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), `content-build-${rel.id}-`));
|
|
404
393
|
try {
|
|
405
394
|
let root;
|
|
406
395
|
if (fs.statSync(source).isDirectory()) {
|
|
@@ -419,9 +408,7 @@ export async function fetchCatalogFromPath(config, rel, source) {
|
|
|
419
408
|
);
|
|
420
409
|
}
|
|
421
410
|
if (manifest.id && manifest.id !== rel.id) {
|
|
422
|
-
throw new Error(
|
|
423
|
-
`${rel.id}: ${source} is package "${manifest.id}", not "${rel.id}"`,
|
|
424
|
-
);
|
|
411
|
+
throw new Error(`${rel.id}: ${source} is package "${manifest.id}", not "${rel.id}"`);
|
|
425
412
|
}
|
|
426
413
|
const version = manifest.version;
|
|
427
414
|
if (!version) {
|
|
@@ -448,9 +435,7 @@ export async function fetchCatalogFromPath(config, rel, source) {
|
|
|
448
435
|
export async function fetchAllCatalogs(config) {
|
|
449
436
|
const rels = itemCatalogRelationships(config);
|
|
450
437
|
if (!rels.length) {
|
|
451
|
-
log.info(
|
|
452
|
-
"No relationship declares `itemCatalog: true`; nothing to fetch.",
|
|
453
|
-
);
|
|
438
|
+
log.info("No relationship declares `itemCatalog: true`; nothing to fetch.");
|
|
454
439
|
return 0;
|
|
455
440
|
}
|
|
456
441
|
for (const rel of rels) await fetchCatalog(config, rel);
|
|
@@ -106,10 +106,7 @@ export function formatUnaddressableFinding(finding, manifestDir) {
|
|
|
106
106
|
const file = path.join(manifestDir, `${finding.package}.json`);
|
|
107
107
|
let at = {};
|
|
108
108
|
try {
|
|
109
|
-
at = positionOfLiteral(
|
|
110
|
-
fs.readFileSync(file, "utf8"),
|
|
111
|
-
`"${finding.sampleKey}"`,
|
|
112
|
-
);
|
|
109
|
+
at = positionOfLiteral(fs.readFileSync(file, "utf8"), `"${finding.sampleKey}"`);
|
|
113
110
|
} catch {
|
|
114
111
|
// Unreadable here is not itself the finding — `loadForeignManifests`
|
|
115
112
|
// already reports that as a stale manifest. The file is simply all that
|
|
@@ -56,10 +56,18 @@
|
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
58
|
import { authoredFields } from "./field-spec.mjs";
|
|
59
|
-
import {
|
|
59
|
+
import { resolveFieldValue, SYSTEM_BLOCK_KEYS, unknownBlockKeys } from "./system-block.mjs";
|
|
60
|
+
import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
60
61
|
import { checkHomepageAddressFields } from "./homepage.mjs";
|
|
61
62
|
import { RETIRED_TYPES } from "./ids.mjs";
|
|
62
|
-
import {
|
|
63
|
+
import { declaredTags } from "./note-vocabulary.mjs";
|
|
64
|
+
import {
|
|
65
|
+
RETIRED_FIELD_ALIASES,
|
|
66
|
+
declaresRetiredAlias,
|
|
67
|
+
draftRetiredMessage,
|
|
68
|
+
readAliasedField,
|
|
69
|
+
retiredAliasMessage,
|
|
70
|
+
} from "./retired-fields.mjs";
|
|
63
71
|
|
|
64
72
|
/**
|
|
65
73
|
* `sohl:` keys every type accepts, whatever its schema says.
|
|
@@ -89,9 +97,29 @@ import { draftRetiredMessage } from "./retired-fields.mjs";
|
|
|
89
97
|
*
|
|
90
98
|
* @type {ReadonlySet<string>}
|
|
91
99
|
*/
|
|
92
|
-
export const UNIVERSAL_KEYS = Object.freeze(
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
export const UNIVERSAL_KEYS = Object.freeze(new Set(["folder", "pack", "archetype", "kbcat"]));
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The system blocks a build checks, and what each accepts beyond the shared
|
|
104
|
+
* vocabulary.
|
|
105
|
+
*
|
|
106
|
+
* One entry, because one system is what every existing tree declares — and the
|
|
107
|
+
* default is a *declaration*, not a hard-coded assumption: a build that ships
|
|
108
|
+
* content for two systems passes both, and each block is then checked against
|
|
109
|
+
* its own vocabulary rather than against the other's (#58). A block nothing
|
|
110
|
+
* declares is not checked, because nothing can say what it may carry, and
|
|
111
|
+
* inventing a rule for it would report a correct tree red.
|
|
112
|
+
*
|
|
113
|
+
* `fieldVocabulary` says the note type's own declared field names are keys of
|
|
114
|
+
* this block. True for `sohl` and untrue in general: those names come from the
|
|
115
|
+
* `itemBuilders` registry that this system declares, and a second system's
|
|
116
|
+
* notes write a second system's fields.
|
|
117
|
+
*
|
|
118
|
+
* @type {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>}
|
|
119
|
+
*/
|
|
120
|
+
export const DEFAULT_SYSTEM_BLOCKS = Object.freeze({
|
|
121
|
+
sohl: Object.freeze({ fieldVocabulary: true }),
|
|
122
|
+
});
|
|
95
123
|
|
|
96
124
|
/**
|
|
97
125
|
* Edit distance, capped — enough to answer "did you mean".
|
|
@@ -166,11 +194,7 @@ export function matchesKind(value, kind) {
|
|
|
166
194
|
value.trim() !== "" &&
|
|
167
195
|
Number.isFinite(Number(value));
|
|
168
196
|
case "boolean":
|
|
169
|
-
return
|
|
170
|
-
typeof value === "boolean" ||
|
|
171
|
-
value === "true" ||
|
|
172
|
-
value === "false"
|
|
173
|
-
);
|
|
197
|
+
return typeof value === "boolean" || value === "true" || value === "false";
|
|
174
198
|
case "string":
|
|
175
199
|
return typeof value !== "object" || value === null;
|
|
176
200
|
case "list":
|
|
@@ -179,9 +203,7 @@ export function matchesKind(value, kind) {
|
|
|
179
203
|
// An emptied map arrives as `[]` from the property editor, and
|
|
180
204
|
// means "this note authors no entries" — the same thing `{}` means.
|
|
181
205
|
return (
|
|
182
|
-
(typeof value === "object" &&
|
|
183
|
-
value !== null &&
|
|
184
|
-
!Array.isArray(value)) ||
|
|
206
|
+
(typeof value === "object" && value !== null && !Array.isArray(value)) ||
|
|
185
207
|
(Array.isArray(value) && value.length === 0)
|
|
186
208
|
);
|
|
187
209
|
default:
|
|
@@ -190,16 +212,208 @@ export function matchesKind(value, kind) {
|
|
|
190
212
|
}
|
|
191
213
|
|
|
192
214
|
/**
|
|
193
|
-
* The `
|
|
215
|
+
* The `data:` container a note authored.
|
|
216
|
+
*
|
|
217
|
+
* An emptied map arrives from the property editor as `[]` and means the same
|
|
218
|
+
* thing `{}` does — this note authors no entries (#8) — so both read as an
|
|
219
|
+
* empty container rather than as a malformed one.
|
|
194
220
|
*
|
|
195
221
|
* @param {object} fm - The note's frontmatter.
|
|
196
|
-
* @returns {object}
|
|
222
|
+
* @returns {{present: boolean, entries: object, malformed: boolean}} What was
|
|
223
|
+
* authored, and whether it is a container at all.
|
|
224
|
+
*/
|
|
225
|
+
function dataBlock(fm) {
|
|
226
|
+
if (!Object.hasOwn(fm ?? {}, "data")) {
|
|
227
|
+
return { present: false, entries: {}, malformed: false };
|
|
228
|
+
}
|
|
229
|
+
const value = fm.data;
|
|
230
|
+
if (value == null || (Array.isArray(value) && value.length === 0)) {
|
|
231
|
+
return { present: true, entries: {}, malformed: false };
|
|
232
|
+
}
|
|
233
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
234
|
+
return { present: true, entries: {}, malformed: true };
|
|
235
|
+
}
|
|
236
|
+
return { present: true, entries: value, malformed: false };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Check a note's `data:` container against the closed vocabulary its type
|
|
241
|
+
* declares (#128).
|
|
242
|
+
*
|
|
243
|
+
* Unlike the top level, which is passed through to the published page and so
|
|
244
|
+
* cannot be refused, `data:` holds the type-specific facts about the subject
|
|
245
|
+
* and every key of it is declared. An unrecognised key is therefore a finding
|
|
246
|
+
* naming the note, with the key it was most likely meant to be — the same
|
|
247
|
+
* capped edit distance {@link nearest} applies to a `sohl:` key, drawn from
|
|
248
|
+
* this type's own vocabulary rather than from every type's.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} note - The note.
|
|
251
|
+
* @param {object} opts
|
|
252
|
+
* @param {string} opts.type - The note's type, for the message.
|
|
253
|
+
* @param {readonly object[]} opts.fields - The type's `data:` declaration.
|
|
254
|
+
* @returns {object[]} Findings.
|
|
255
|
+
*/
|
|
256
|
+
function checkDataContainer(note, { type, fields }) {
|
|
257
|
+
const findings = [];
|
|
258
|
+
const { present, entries, malformed } = dataBlock(note.fm ?? {});
|
|
259
|
+
if (!present) return findings;
|
|
260
|
+
|
|
261
|
+
const raw = note.raw ?? "";
|
|
262
|
+
if (malformed) {
|
|
263
|
+
findings.push({
|
|
264
|
+
file: note.file,
|
|
265
|
+
...positionOfFrontmatterPath(raw, ["data"], { key: true }),
|
|
266
|
+
severity: "error",
|
|
267
|
+
message:
|
|
268
|
+
"`data:` must be a map of the note's own type-specific " +
|
|
269
|
+
`properties, but reads ${JSON.stringify(note.fm.data)}`,
|
|
270
|
+
});
|
|
271
|
+
return findings;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** First segment of each declared name — `charges.value` is authored as `charges`. */
|
|
275
|
+
const declared = new Set(fields.map((f) => f.name.split(".")[0]));
|
|
276
|
+
|
|
277
|
+
for (const key of Object.keys(entries)) {
|
|
278
|
+
if (declared.has(key)) continue;
|
|
279
|
+
const guess = nearest(key, declared);
|
|
280
|
+
findings.push({
|
|
281
|
+
file: note.file,
|
|
282
|
+
...positionOfFrontmatterPath(raw, ["data", key], { key: true }),
|
|
283
|
+
severity: "error",
|
|
284
|
+
message:
|
|
285
|
+
`"${key}" is not a \`data:\` property declared by ${type}; ` +
|
|
286
|
+
`the container is closed, so unlike a top-level key it is ` +
|
|
287
|
+
`not passed through to the page` +
|
|
288
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const field of fields) {
|
|
293
|
+
if (!field.kind) continue;
|
|
294
|
+
const segments = field.name.split(".");
|
|
295
|
+
let value = entries;
|
|
296
|
+
for (const segment of segments) {
|
|
297
|
+
value = value && typeof value === "object" ? value[segment] : undefined;
|
|
298
|
+
}
|
|
299
|
+
if (value === undefined || value === null) continue;
|
|
300
|
+
if (matchesKind(value, field.kind)) continue;
|
|
301
|
+
findings.push({
|
|
302
|
+
file: note.file,
|
|
303
|
+
...positionOfFrontmatterPath(raw, ["data", ...segments]),
|
|
304
|
+
severity: "error",
|
|
305
|
+
message:
|
|
306
|
+
`\`data.${field.name}\` should be ${field.shape ?? field.kind}, ` +
|
|
307
|
+
`but reads ${JSON.stringify(value)}`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return findings;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Check a note's top-level `subType` against the values its type declares
|
|
316
|
+
* (#128).
|
|
317
|
+
*
|
|
318
|
+
* `subType` stays at the top level — it is what each system's map reads to
|
|
319
|
+
* derive a document type, so it describes the note rather than the subject —
|
|
320
|
+
* but it is not open like the rest of that region: a type either declares a
|
|
321
|
+
* `subType` or does not, and a type that does declares its values.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} note - The note.
|
|
324
|
+
* @param {object} opts
|
|
325
|
+
* @param {string} opts.type - The note's type, for the message.
|
|
326
|
+
* @param {object} opts.entry - The type's vocabulary entry.
|
|
327
|
+
* @returns {object[]} Findings.
|
|
197
328
|
*/
|
|
198
|
-
function
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
329
|
+
function checkSubType(note, { type, entry }) {
|
|
330
|
+
const fm = note.fm ?? {};
|
|
331
|
+
if (!Object.hasOwn(fm, "subType") || fm.subType == null || fm.subType === "") return [];
|
|
332
|
+
|
|
333
|
+
const value = String(fm.subType);
|
|
334
|
+
const at = positionInFrontmatter(note.raw ?? "", "subType");
|
|
335
|
+
|
|
336
|
+
if (!Object.hasOwn(entry, "subTypes")) {
|
|
337
|
+
return [
|
|
338
|
+
{
|
|
339
|
+
file: note.file,
|
|
340
|
+
...at,
|
|
341
|
+
severity: "error",
|
|
342
|
+
message:
|
|
343
|
+
`\`subType\` is not a property declared by ${type}; it ` +
|
|
344
|
+
`declares no subtypes, so nothing reads this value`,
|
|
345
|
+
},
|
|
346
|
+
];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const values = entry.subTypes;
|
|
350
|
+
// `null` is "declared, values not yet enumerated" — presence is legal and
|
|
351
|
+
// the value is nobody's to check yet.
|
|
352
|
+
if (values == null || values.includes(value)) return [];
|
|
353
|
+
|
|
354
|
+
const guess = nearest(value, values);
|
|
355
|
+
return [
|
|
356
|
+
{
|
|
357
|
+
file: note.file,
|
|
358
|
+
...at,
|
|
359
|
+
severity: "error",
|
|
360
|
+
message:
|
|
361
|
+
`\`subType\` "${value}" is not one of the subtypes ` +
|
|
362
|
+
`${type} declares (${values.join(", ")})` +
|
|
363
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Check a note's `tags` for near misses against the tags that classify (#172).
|
|
370
|
+
*
|
|
371
|
+
* `tags:` is top-level and the top level is open, so an unrecognised tag is
|
|
372
|
+
* **not** a finding: a theme, a region or a working state is the author's own
|
|
373
|
+
* vocabulary and this build has no standing to refuse it.
|
|
374
|
+
*
|
|
375
|
+
* **Distance alone is not enough either**, which the corpus settles rather than
|
|
376
|
+
* argues: `azravan` on a faith, `barter` on an economy note and `secret` on
|
|
377
|
+
* three lore notes are all within a typo's distance of `caravan`, `border` and
|
|
378
|
+
* `sacred`, and not one is a mistake. Checked against every declared tag at
|
|
379
|
+
* once, the rule was wrong on every note it touched.
|
|
380
|
+
*
|
|
381
|
+
* **The scope is what makes it sound.** Each group names the types it applies
|
|
382
|
+
* to, so a place's kinds are only ever checked on a place, and the eight
|
|
383
|
+
* findings above become none while a settlement tagged `vilage` is still
|
|
384
|
+
* caught.
|
|
385
|
+
*
|
|
386
|
+
* @param {object} note - The note.
|
|
387
|
+
* @param {object} opts
|
|
388
|
+
* @param {readonly string[]} opts.tags - Every declared tag, flattened.
|
|
389
|
+
* @returns {object[]} Findings.
|
|
390
|
+
*/
|
|
391
|
+
function checkTags(note, { type }) {
|
|
392
|
+
const authored = (note.fm ?? {}).tags;
|
|
393
|
+
if (!Array.isArray(authored)) return [];
|
|
394
|
+
|
|
395
|
+
const tags = declaredTags(type);
|
|
396
|
+
if (!tags.length) return [];
|
|
397
|
+
const declared = new Set(tags);
|
|
398
|
+
const findings = [];
|
|
399
|
+
for (const raw of authored) {
|
|
400
|
+
if (typeof raw !== "string" || !raw.trim()) continue;
|
|
401
|
+
const value = raw.trim();
|
|
402
|
+
if (declared.has(value)) continue;
|
|
403
|
+
const guess = nearest(value, tags);
|
|
404
|
+
if (!guess) continue;
|
|
405
|
+
findings.push({
|
|
406
|
+
file: note.file,
|
|
407
|
+
...positionInFrontmatter(note.raw ?? "", "tags"),
|
|
408
|
+
severity: "error",
|
|
409
|
+
message:
|
|
410
|
+
`tag "${value}" is not declared, and is a near miss for the declared ` +
|
|
411
|
+
`tag "${guess}". A classifying tag is queried, so a misspelt one drops ` +
|
|
412
|
+
`this note out of an index without failing anything. Write "${guess}", ` +
|
|
413
|
+
`or rename the tag so it is plainly the author's own`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return findings;
|
|
203
417
|
}
|
|
204
418
|
|
|
205
419
|
/**
|
|
@@ -210,15 +424,23 @@ function sohlBlock(fm) {
|
|
|
210
424
|
* @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
|
|
211
425
|
* @param {object} [opts.index] - The link index, for the reference check. Its
|
|
212
426
|
* absence skips that check rather than reporting every reference as dead.
|
|
427
|
+
* @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
|
|
428
|
+
* it declares, as `engine/note-vocabulary.mjs` states them (#128). Supplied
|
|
429
|
+
* by the caller for the same reason `schemas` is: this module validates a
|
|
430
|
+
* note against whatever its type declares and knows no type names of its
|
|
431
|
+
* own. Its absence skips the `data:` and `subType` checks rather than
|
|
432
|
+
* reporting every key as unknown.
|
|
433
|
+
* @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
|
|
434
|
+
* The system blocks to check, and what each accepts. See
|
|
435
|
+
* {@link DEFAULT_SYSTEM_BLOCKS}.
|
|
213
436
|
* @returns {object[]} Findings, each with a locator where one is obtainable.
|
|
214
437
|
*/
|
|
215
|
-
export function lintNote(note, { schemas, index }) {
|
|
438
|
+
export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_SYSTEM_BLOCKS }) {
|
|
216
439
|
const findings = [];
|
|
217
440
|
const fm = note.fm ?? {};
|
|
218
441
|
const type = String(fm.type ?? "");
|
|
219
442
|
const raw = () => note.raw ?? "";
|
|
220
|
-
const at = (key, literal) =>
|
|
221
|
-
positionInFrontmatter(raw(), key, literal ?? undefined);
|
|
443
|
+
const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
|
|
222
444
|
|
|
223
445
|
// The retired top-level fields, checked before the type: a note may carry
|
|
224
446
|
// one whatever its type is, and each finding stands on its own. Reported
|
|
@@ -250,6 +472,11 @@ export function lintNote(note, { schemas, index }) {
|
|
|
250
472
|
// fields above because it is the same kind of statement — a top-level key
|
|
251
473
|
// this note may not write — and, like them, it must survive the two early
|
|
252
474
|
// returns below: the finding stands whatever else the type is.
|
|
475
|
+
// Tags are checked here for the same reason: a classifying tag is not a
|
|
476
|
+
// type's property — `draft` belongs to any note and `village` to a place —
|
|
477
|
+
// so the finding must survive the early returns below.
|
|
478
|
+
findings.push(...checkTags(note, { type }));
|
|
479
|
+
|
|
253
480
|
for (const { key, message } of checkHomepageAddressFields(fm)) {
|
|
254
481
|
findings.push({
|
|
255
482
|
file: note.file,
|
|
@@ -286,43 +513,114 @@ export function lintNote(note, { schemas, index }) {
|
|
|
286
513
|
return findings;
|
|
287
514
|
}
|
|
288
515
|
|
|
516
|
+
// The closed frontmatter regions (#128), checked beside the `sohl:` block
|
|
517
|
+
// because they are the same statement about the same note: this key is not
|
|
518
|
+
// one this type may write. Skipped entirely when the caller declares no
|
|
519
|
+
// vocabulary — reporting every key as unknown because nothing was loaded
|
|
520
|
+
// to recognise it would be worse than not checking.
|
|
521
|
+
const entry = vocabulary?.[type];
|
|
522
|
+
if (entry) {
|
|
523
|
+
findings.push(...checkDataContainer(note, { type, fields: entry.data ?? [] }));
|
|
524
|
+
findings.push(...checkSubType(note, { type, entry }));
|
|
525
|
+
}
|
|
526
|
+
|
|
289
527
|
const fields = authoredFields(schema);
|
|
290
|
-
const block = sohlBlock(fm);
|
|
291
528
|
/** First segment of each declared name — `impact.die` is authored as `impact`. */
|
|
292
529
|
const declared = new Set(fields.map((f) => f.name.split(".")[0]));
|
|
293
530
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
531
|
+
// The retired spelling of a field this type declares → what to write now.
|
|
532
|
+
// Built from the type's own vocabulary, so a renamed field is retired
|
|
533
|
+
// exactly where its replacement exists and the old name stays an unknown
|
|
534
|
+
// key everywhere else (#142).
|
|
535
|
+
const renamed = new Map();
|
|
536
|
+
for (const name of declared) {
|
|
537
|
+
const retired = RETIRED_FIELD_ALIASES[name];
|
|
538
|
+
if (retired) renamed.set(retired, name);
|
|
539
|
+
}
|
|
540
|
+
for (const [retired, current] of renamed) {
|
|
541
|
+
// `declaresRetiredAlias` searches both regions, because both are read:
|
|
542
|
+
// a note that moved the key to the top level without renaming it has
|
|
543
|
+
// done half the migration. It is the same predicate the compile-time
|
|
544
|
+
// report asks, so the two cannot disagree about what a note declares.
|
|
545
|
+
if (!declaresRetiredAlias(fm, current)) continue;
|
|
297
546
|
findings.push({
|
|
298
547
|
file: note.file,
|
|
299
|
-
...at(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
548
|
+
...at(retired),
|
|
549
|
+
// A warning, not an error: the note compiles to the correct
|
|
550
|
+
// document, so failing a build over it would red a tree that has
|
|
551
|
+
// done nothing wrong yet. The refusal comes after the sweep, as
|
|
552
|
+
// `package:`'s did (#56).
|
|
553
|
+
severity: "warning",
|
|
554
|
+
message: retiredAliasMessage(retired, current),
|
|
305
555
|
});
|
|
306
556
|
}
|
|
307
557
|
|
|
558
|
+
// Every declared system's block, each against its own vocabulary (#58). A
|
|
559
|
+
// block carries the shared keys any system's does — `system`, `type`,
|
|
560
|
+
// `img`, `effects`, `flags`, `pack` — plus whatever that system declares:
|
|
561
|
+
// for `sohl`, the note type's own field names, which are still the position
|
|
562
|
+
// the corpus authors them at until #126 moves them.
|
|
563
|
+
for (const [blockName, spec] of Object.entries(systems ?? {})) {
|
|
564
|
+
const accepted = new Set([
|
|
565
|
+
...UNIVERSAL_KEYS,
|
|
566
|
+
...(spec?.known ?? []),
|
|
567
|
+
...(spec?.fieldVocabulary ? declared : []),
|
|
568
|
+
]);
|
|
569
|
+
for (const key of unknownBlockKeys(fm, blockName, { known: accepted })) {
|
|
570
|
+
// Reported above, with what to write instead — a retired spelling
|
|
571
|
+
// is a rename to schedule, not a key nobody recognises. Only where
|
|
572
|
+
// the block's vocabulary is this type's: the alias renames *this*
|
|
573
|
+
// system's field, and another system's like-spelled key is not it.
|
|
574
|
+
if (spec?.fieldVocabulary && renamed.has(key)) continue;
|
|
575
|
+
const guess = nearest(key, [...accepted, ...SYSTEM_BLOCK_KEYS]);
|
|
576
|
+
findings.push({
|
|
577
|
+
file: note.file,
|
|
578
|
+
...at(key),
|
|
579
|
+
severity: "error",
|
|
580
|
+
message:
|
|
581
|
+
`"${key}" is not a property of a ${type}` +
|
|
582
|
+
(blockName === "sohl" ? "" : ` under \`${blockName}\``) +
|
|
583
|
+
`; it is discarded at compile with no warning` +
|
|
584
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
308
589
|
for (const field of fields) {
|
|
309
590
|
// Only top-level names are read here: a nested one (`impact.die`) is
|
|
310
591
|
// reached through its parent, and reporting the parent twice — once as
|
|
311
592
|
// itself and once as its child — helps nobody.
|
|
312
|
-
const [head
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
593
|
+
const [head] = field.name.split(".");
|
|
594
|
+
// Resolved exactly as the compiler resolves it (#58): the system path
|
|
595
|
+
// first, then the block, then the declared shared source. A lint that
|
|
596
|
+
// read only one of the three would report a note's own field as missing
|
|
597
|
+
// the moment it moved to another of them. A **shared** field needs
|
|
598
|
+
// nothing extra here — the note's top level *is* the third step — so
|
|
599
|
+
// `shared` says only where the field's home is, for the message below.
|
|
600
|
+
let { value, from } = resolveFieldValue(field, fm, { block: "sohl" });
|
|
601
|
+
// A **renamed** field may still be written under its retired spelling,
|
|
602
|
+
// which that order knows nothing about. It resolves through the reader
|
|
603
|
+
// the compiler uses, so the lint cannot disagree with the build about
|
|
604
|
+
// which value a note carries (#142).
|
|
605
|
+
if ((from === "default" || value == null) && RETIRED_FIELD_ALIASES[field.name]) {
|
|
606
|
+
const aliased = readAliasedField(fm, field.name);
|
|
607
|
+
if (aliased !== undefined) {
|
|
608
|
+
value = aliased;
|
|
609
|
+
from = "block";
|
|
610
|
+
}
|
|
317
611
|
}
|
|
318
|
-
const absent = value === undefined || value === null;
|
|
612
|
+
const absent = from === "default" || value === undefined || value === null;
|
|
613
|
+
// Where the field belongs, as a message names it: a shared field is not
|
|
614
|
+
// under `sohl:`, so telling an author to write `sohl.img` would send
|
|
615
|
+
// them to the wrong region.
|
|
616
|
+
const label = field.shared ? `\`${field.name}\`` : `\`sohl.${field.name}\``;
|
|
319
617
|
|
|
320
618
|
if (field.required && absent) {
|
|
321
619
|
findings.push({
|
|
322
620
|
file: note.file,
|
|
323
621
|
...at("type", type),
|
|
324
622
|
severity: "error",
|
|
325
|
-
message: `a ${type} must declare
|
|
623
|
+
message: `a ${type} must declare ${label} — ${field.describe}`,
|
|
326
624
|
});
|
|
327
625
|
continue;
|
|
328
626
|
}
|
|
@@ -334,7 +632,7 @@ export function lintNote(note, { schemas, index }) {
|
|
|
334
632
|
...at(head),
|
|
335
633
|
severity: "error",
|
|
336
634
|
message:
|
|
337
|
-
|
|
635
|
+
`${label} should be ${field.shape ?? field.kind}, ` +
|
|
338
636
|
`but reads ${JSON.stringify(value)}`,
|
|
339
637
|
});
|
|
340
638
|
continue;
|
|
@@ -352,7 +650,7 @@ export function lintNote(note, { schemas, index }) {
|
|
|
352
650
|
...at(head, value),
|
|
353
651
|
severity: "error",
|
|
354
652
|
message:
|
|
355
|
-
|
|
653
|
+
`${label} names ${field.ref} ` +
|
|
356
654
|
`"${value}", and no note or vendored manifest declares it`,
|
|
357
655
|
});
|
|
358
656
|
}
|
|
@@ -368,11 +666,15 @@ export function lintNote(note, { schemas, index }) {
|
|
|
368
666
|
* @param {object} index - From `buildLinkIndex`.
|
|
369
667
|
* @param {object} opts
|
|
370
668
|
* @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
|
|
669
|
+
* @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
|
|
670
|
+
* it declares (#128); see {@link lintNote}.
|
|
371
671
|
* @param {boolean} [opts.references=true] - Whether to check references.
|
|
672
|
+
* @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
|
|
673
|
+
* The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
|
|
372
674
|
* @returns {{findings: object[], notes: number}} The findings, and how many
|
|
373
675
|
* notes were inspected.
|
|
374
676
|
*/
|
|
375
|
-
export function lintFrontmatter(index, { schemas, references = true }) {
|
|
677
|
+
export function lintFrontmatter(index, { schemas, vocabulary, references = true, systems }) {
|
|
376
678
|
const findings = [];
|
|
377
679
|
const notes = [...index.notes].sort((a, b) =>
|
|
378
680
|
a.file < b.file ? -1
|
|
@@ -383,7 +685,9 @@ export function lintFrontmatter(index, { schemas, references = true }) {
|
|
|
383
685
|
findings.push(
|
|
384
686
|
...lintNote(note, {
|
|
385
687
|
schemas,
|
|
688
|
+
vocabulary,
|
|
386
689
|
index: references ? index : undefined,
|
|
690
|
+
...(systems ? { systems } : {}),
|
|
387
691
|
}),
|
|
388
692
|
);
|
|
389
693
|
}
|
package/engine/frontmatter.mjs
CHANGED
|
@@ -119,8 +119,7 @@ export function resolveCharges(fm) {
|
|
|
119
119
|
// A blank maximum means "does not use charges" — a stray current count
|
|
120
120
|
// cannot outlive it, since the logic layer disables both modifiers.
|
|
121
121
|
return {
|
|
122
|
-
value:
|
|
123
|
-
max === null ? null : toCount(sohlField(fm, "charges.value", null)),
|
|
122
|
+
value: max === null ? null : toCount(sohlField(fm, "charges.value", null)),
|
|
124
123
|
max,
|
|
125
124
|
};
|
|
126
125
|
}
|
|
@@ -149,9 +148,7 @@ export function resolveCharges(fm) {
|
|
|
149
148
|
export function resolveSkillAptitudes(fm, ctx = "item") {
|
|
150
149
|
const entries = readMapEntries(fm, "skillAptitudes");
|
|
151
150
|
if (entries === null) {
|
|
152
|
-
throw new Error(
|
|
153
|
-
`${ctx}: skillAptitudes must be a map of selector → number`,
|
|
154
|
-
);
|
|
151
|
+
throw new Error(`${ctx}: skillAptitudes must be a map of selector → number`);
|
|
155
152
|
}
|
|
156
153
|
const out = {};
|
|
157
154
|
for (const [selector, value] of entries) {
|
|
@@ -187,9 +184,7 @@ export function resolveSkillAptitudes(fm, ctx = "item") {
|
|
|
187
184
|
export function resolveRelation(fm, ctx = "item") {
|
|
188
185
|
const entries = readMapEntries(fm, "relation");
|
|
189
186
|
if (entries === null) {
|
|
190
|
-
throw new Error(
|
|
191
|
-
`${ctx}: relation must be a map of shortcode → standing`,
|
|
192
|
-
);
|
|
187
|
+
throw new Error(`${ctx}: relation must be a map of shortcode → standing`);
|
|
193
188
|
}
|
|
194
189
|
const out = {};
|
|
195
190
|
for (const [code, value] of entries) {
|