@heroiclands/package-build 9.0.0 → 10.0.1
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 +721 -0
- package/CONTENT.md +273 -13
- package/bin/content-build.mjs +437 -7
- package/content-config.mjs +259 -28
- package/docs/content-format.md +1418 -0
- package/engine/address-charset.mjs +62 -0
- package/engine/alias-index.mjs +153 -0
- package/engine/base-compiler.mjs +194 -4
- package/engine/content-address.mjs +4 -4
- package/engine/content-format-check.mjs +570 -0
- package/engine/content-format.mjs +253 -0
- package/engine/content-links.mjs +132 -56
- package/engine/content-lint.mjs +8 -1
- package/engine/diagnostics.mjs +33 -0
- package/engine/document-subtypes.mjs +440 -0
- package/engine/field-spec.mjs +49 -43
- package/engine/frontmatter-lint.mjs +351 -28
- package/engine/generate.mjs +32 -4
- package/engine/helpers.mjs +41 -29
- package/engine/ids.mjs +19 -1
- package/engine/index.mjs +15 -0
- package/engine/item-registry.mjs +72 -5
- package/engine/kb-manifest.mjs +36 -7
- package/engine/map-notes.mjs +34 -18
- package/engine/note-claims.mjs +383 -0
- package/engine/note-vocabulary.mjs +678 -0
- package/engine/pack-config.mjs +39 -22
- package/engine/pack-router.mjs +17 -6
- package/engine/prose-lint.mjs +55 -3
- package/engine/retired-fields.mjs +117 -3
- package/engine/scenes.mjs +19 -1
- package/engine/schema-check.mjs +347 -3
- package/engine/site-build.mjs +1 -1
- package/engine/site-index.mjs +38 -21
- package/engine/system-block.mjs +513 -0
- package/engine/web-wikilinks.mjs +112 -80
- package/engine/wikilink-syntax.mjs +30 -0
- package/engine/wikilinks.mjs +67 -51
- package/package.json +6 -2
- package/sohl/actors.mjs +249 -36
- package/sohl/document-subtypes.mjs +82 -0
- package/sohl/index.mjs +3 -0
- package/sohl/items.mjs +110 -14
- package/sohl/note-schemas.mjs +11 -7
- package/types/content-config.d.mts +48 -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 +14 -1
- package/types/engine/helpers.d.mts +21 -13
- 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/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/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
|
@@ -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.
|
|
@@ -91,6 +99,28 @@ import { draftRetiredMessage } from "./retired-fields.mjs";
|
|
|
91
99
|
*/
|
|
92
100
|
export const UNIVERSAL_KEYS = Object.freeze(new Set(["folder", "pack", "archetype", "kbcat"]));
|
|
93
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
|
+
});
|
|
123
|
+
|
|
94
124
|
/**
|
|
95
125
|
* Edit distance, capped — enough to answer "did you mean".
|
|
96
126
|
*
|
|
@@ -182,14 +212,208 @@ export function matchesKind(value, kind) {
|
|
|
182
212
|
}
|
|
183
213
|
|
|
184
214
|
/**
|
|
185
|
-
* 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.
|
|
186
220
|
*
|
|
187
221
|
* @param {object} fm - The note's frontmatter.
|
|
188
|
-
* @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.
|
|
189
328
|
*/
|
|
190
|
-
function
|
|
191
|
-
const
|
|
192
|
-
|
|
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;
|
|
193
417
|
}
|
|
194
418
|
|
|
195
419
|
/**
|
|
@@ -200,9 +424,18 @@ function sohlBlock(fm) {
|
|
|
200
424
|
* @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
|
|
201
425
|
* @param {object} [opts.index] - The link index, for the reference check. Its
|
|
202
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}.
|
|
203
436
|
* @returns {object[]} Findings, each with a locator where one is obtainable.
|
|
204
437
|
*/
|
|
205
|
-
export function lintNote(note, { schemas, index }) {
|
|
438
|
+
export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_SYSTEM_BLOCKS }) {
|
|
206
439
|
const findings = [];
|
|
207
440
|
const fm = note.fm ?? {};
|
|
208
441
|
const type = String(fm.type ?? "");
|
|
@@ -239,6 +472,11 @@ export function lintNote(note, { schemas, index }) {
|
|
|
239
472
|
// fields above because it is the same kind of statement — a top-level key
|
|
240
473
|
// this note may not write — and, like them, it must survive the two early
|
|
241
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
|
+
|
|
242
480
|
for (const { key, message } of checkHomepageAddressFields(fm)) {
|
|
243
481
|
findings.push({
|
|
244
482
|
file: note.file,
|
|
@@ -275,42 +513,114 @@ export function lintNote(note, { schemas, index }) {
|
|
|
275
513
|
return findings;
|
|
276
514
|
}
|
|
277
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
|
+
|
|
278
527
|
const fields = authoredFields(schema);
|
|
279
|
-
const block = sohlBlock(fm);
|
|
280
528
|
/** First segment of each declared name — `impact.die` is authored as `impact`. */
|
|
281
529
|
const declared = new Set(fields.map((f) => f.name.split(".")[0]));
|
|
282
530
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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;
|
|
286
546
|
findings.push({
|
|
287
547
|
file: note.file,
|
|
288
|
-
...at(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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),
|
|
294
555
|
});
|
|
295
556
|
}
|
|
296
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
|
+
|
|
297
589
|
for (const field of fields) {
|
|
298
590
|
// Only top-level names are read here: a nested one (`impact.die`) is
|
|
299
591
|
// reached through its parent, and reporting the parent twice — once as
|
|
300
592
|
// itself and once as its child — helps nobody.
|
|
301
|
-
const [head
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
+
}
|
|
305
611
|
}
|
|
306
|
-
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}\``;
|
|
307
617
|
|
|
308
618
|
if (field.required && absent) {
|
|
309
619
|
findings.push({
|
|
310
620
|
file: note.file,
|
|
311
621
|
...at("type", type),
|
|
312
622
|
severity: "error",
|
|
313
|
-
message: `a ${type} must declare
|
|
623
|
+
message: `a ${type} must declare ${label} — ${field.describe}`,
|
|
314
624
|
});
|
|
315
625
|
continue;
|
|
316
626
|
}
|
|
@@ -322,7 +632,7 @@ export function lintNote(note, { schemas, index }) {
|
|
|
322
632
|
...at(head),
|
|
323
633
|
severity: "error",
|
|
324
634
|
message:
|
|
325
|
-
|
|
635
|
+
`${label} should be ${field.shape ?? field.kind}, ` +
|
|
326
636
|
`but reads ${JSON.stringify(value)}`,
|
|
327
637
|
});
|
|
328
638
|
continue;
|
|
@@ -332,15 +642,22 @@ export function lintNote(note, { schemas, index }) {
|
|
|
332
642
|
// link index's own resolver, so a cross-package reference answered by a
|
|
333
643
|
// vendored manifest lands exactly as the same address in a wikilink
|
|
334
644
|
// would — rather than through a second, subtly different rule.
|
|
645
|
+
//
|
|
646
|
+
// **As an address, always.** The resolver's third argument chooses the
|
|
647
|
+
// namespace and has no fallback (#144): omitted, it reads the target as
|
|
648
|
+
// an *alias*, and `type-shortcode` is never an alias, so every value a
|
|
649
|
+
// `ref:` field carries was reported unresolvable (#176). A frontmatter
|
|
650
|
+
// reference is a bare address by construction — there is no pipe to
|
|
651
|
+
// read intent from, and the field supplies the type — so it says so.
|
|
335
652
|
if (field.ref && index && typeof value === "string" && value) {
|
|
336
653
|
const target = `${field.ref}-${value}`;
|
|
337
|
-
if (!index.resolve(note, target) && !index.manifestHit(target)) {
|
|
654
|
+
if (!index.resolve(note, target, true) && !index.manifestHit(target)) {
|
|
338
655
|
findings.push({
|
|
339
656
|
file: note.file,
|
|
340
657
|
...at(head, value),
|
|
341
658
|
severity: "error",
|
|
342
659
|
message:
|
|
343
|
-
|
|
660
|
+
`${label} names ${field.ref} ` +
|
|
344
661
|
`"${value}", and no note or vendored manifest declares it`,
|
|
345
662
|
});
|
|
346
663
|
}
|
|
@@ -356,11 +673,15 @@ export function lintNote(note, { schemas, index }) {
|
|
|
356
673
|
* @param {object} index - From `buildLinkIndex`.
|
|
357
674
|
* @param {object} opts
|
|
358
675
|
* @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
|
|
676
|
+
* @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
|
|
677
|
+
* it declares (#128); see {@link lintNote}.
|
|
359
678
|
* @param {boolean} [opts.references=true] - Whether to check references.
|
|
679
|
+
* @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
|
|
680
|
+
* The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
|
|
360
681
|
* @returns {{findings: object[], notes: number}} The findings, and how many
|
|
361
682
|
* notes were inspected.
|
|
362
683
|
*/
|
|
363
|
-
export function lintFrontmatter(index, { schemas, references = true }) {
|
|
684
|
+
export function lintFrontmatter(index, { schemas, vocabulary, references = true, systems }) {
|
|
364
685
|
const findings = [];
|
|
365
686
|
const notes = [...index.notes].sort((a, b) =>
|
|
366
687
|
a.file < b.file ? -1
|
|
@@ -371,7 +692,9 @@ export function lintFrontmatter(index, { schemas, references = true }) {
|
|
|
371
692
|
findings.push(
|
|
372
693
|
...lintNote(note, {
|
|
373
694
|
schemas,
|
|
695
|
+
vocabulary,
|
|
374
696
|
index: references ? index : undefined,
|
|
697
|
+
...(systems ? { systems } : {}),
|
|
375
698
|
}),
|
|
376
699
|
);
|
|
377
700
|
}
|
package/engine/generate.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import { countContentNotes } from "./content-tree.mjs";
|
|
|
53
53
|
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
54
54
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
55
55
|
import { routerFor } from "./pack-router.mjs";
|
|
56
|
+
import { unclaimedNoteFindings } from "./note-claims.mjs";
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
59
|
* The compiler class for each Foundry document type a pack may hold.
|
|
@@ -94,16 +95,30 @@ export const packJsonDir = (name, config = loadPackConfig()) =>
|
|
|
94
95
|
* order among packs of one type — and every one of them is written before the
|
|
95
96
|
* actors pass that reads them.
|
|
96
97
|
*
|
|
98
|
+
* **Scoped to one system when the caller has one (#58).** A being addresses an
|
|
99
|
+
* item by `(type, shortcode)`, and that address is unique within one system and
|
|
100
|
+
* not across two: `skill:sword` is an HM3 skill *and* a SoHL skill, with
|
|
101
|
+
* different data models behind them. The reference itself is unambiguous — it
|
|
102
|
+
* sits inside a system block, so position says which it means — but the
|
|
103
|
+
* resolver has to know which catalogue it is searching, or it resolves the pair
|
|
104
|
+
* by whichever pack was read first. So an Actor pass reads the Item packs of
|
|
105
|
+
* **its own** system plus the system-neutral ones, which belong to every
|
|
106
|
+
* system. Asking for no system reads them all, which is every single-system
|
|
107
|
+
* build and the behaviour this always had.
|
|
108
|
+
*
|
|
97
109
|
* @param {object} [config] - The resolved build configuration. Defaults to this
|
|
98
110
|
* repository's.
|
|
111
|
+
* @param {string|null} [system] - The system whose catalogue is wanted. Omitted
|
|
112
|
+
* or `null`, every Item pack is read.
|
|
99
113
|
* @returns {string[]} Each Item pack's JSON directory. Empty when the
|
|
100
114
|
* repository ships no items at all, which is a legitimate package: the actors
|
|
101
115
|
* pass accepts an empty list and reports an item it cannot resolve per
|
|
102
116
|
* `(type, shortcode)` instead, naming the being (#49).
|
|
103
117
|
*/
|
|
104
|
-
export function itemPackJsonDirs(config = loadPackConfig()) {
|
|
118
|
+
export function itemPackJsonDirs(config = loadPackConfig(), system = null) {
|
|
105
119
|
return config.packs
|
|
106
120
|
.filter((pack) => pack.type === "Item")
|
|
121
|
+
.filter((pack) => system == null || !pack.system || pack.system === system)
|
|
107
122
|
.map((pack) => packJsonDir(pack.name, config));
|
|
108
123
|
}
|
|
109
124
|
|
|
@@ -296,7 +311,9 @@ async function generatePack(
|
|
|
296
311
|
// the Item packs, so the dependency is stated rather than assumed
|
|
297
312
|
// (#1508) — and it is every Item pack, since a repository may ship more
|
|
298
313
|
// than one (#1566).
|
|
299
|
-
|
|
314
|
+
// Scoped to this pack's system, so a being resolves `(type, shortcode)`
|
|
315
|
+
// against its own system's catalogue and the neutral one (#58).
|
|
316
|
+
itemsSourceDirs: itemPackJsonDirs(config, system ?? null),
|
|
300
317
|
// The catalogue of a package this repository depends on but does
|
|
301
318
|
// not contain, for a repository that authors beings without
|
|
302
319
|
// holding the items they are assembled from. Cache-only: a cold
|
|
@@ -381,6 +398,17 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
|
|
|
381
398
|
return 1;
|
|
382
399
|
}
|
|
383
400
|
log.info(`Content tree: ${noteCount} note(s) at ${contentBase}`);
|
|
401
|
+
|
|
402
|
+
// A note whose `type:` no configured pack claims compiles into nothing, and
|
|
403
|
+
// used to say nothing (#146) — no pass got far enough to reject it, so the
|
|
404
|
+
// silence had no owner. Asked once, of the whole configuration, because
|
|
405
|
+
// that is the only place it can be answered: a per-pass check would report
|
|
406
|
+
// every type a system deliberately does not map, which is exactly the
|
|
407
|
+
// silence #79 requires. Independent of `only`, since it is a fact about the
|
|
408
|
+
// configured pack list rather than about which passes this run executes.
|
|
409
|
+
const unclaimed = unclaimedNoteFindings(config);
|
|
410
|
+
for (const finding of unclaimed) emitDiagnostic(finding);
|
|
411
|
+
|
|
384
412
|
fs.mkdirSync(config.paths.packJson, { recursive: true });
|
|
385
413
|
|
|
386
414
|
// A companion pack has no pass of its own — naming it selects the pass that
|
|
@@ -419,10 +447,10 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
|
|
|
419
447
|
for (const message of unsatisfied) {
|
|
420
448
|
emitDiagnostic({ severity: "error", message });
|
|
421
449
|
}
|
|
422
|
-
return unsatisfied.length;
|
|
450
|
+
return unsatisfied.length + unclaimed.length;
|
|
423
451
|
}
|
|
424
452
|
|
|
425
|
-
let totalErrors =
|
|
453
|
+
let totalErrors = unclaimed.length;
|
|
426
454
|
const passes = [];
|
|
427
455
|
for (const pack of ordered) {
|
|
428
456
|
const { errors, compiled } = await generatePack(
|