@heroiclands/package-build 9.0.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 +694 -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 +343 -27
- 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
package/engine/schema-check.mjs
CHANGED
|
@@ -32,10 +32,20 @@
|
|
|
32
32
|
* documents' `system` keys against `defineSchema()` **by hand**, which is how
|
|
33
33
|
* the next one would have to be found too.
|
|
34
34
|
*
|
|
35
|
-
* **The
|
|
35
|
+
* **The declared half of the emission is statically known.** A builder *is* its
|
|
36
36
|
* field list — {@link module:engine/field-spec} makes `buildFromFields` the only
|
|
37
|
-
* statement of the mapping — so every `system` path a
|
|
38
|
-
* `field.to
|
|
37
|
+
* statement of the mapping — so every `system` path a *declared field* emits is
|
|
38
|
+
* `field.to`. {@link compareFields} needs no compilation to read that.
|
|
39
|
+
*
|
|
40
|
+
* **The rest of the emission is only observable.** A compiler writes keys of
|
|
41
|
+
* its own alongside the declared fields — `shortcode`, `actionDefs`, `notes`,
|
|
42
|
+
* `docHtml`, and since #126 `archetype` — and those appear in neither set
|
|
43
|
+
* {@link compareFields} compares, so nothing compared them at all (#155). They
|
|
44
|
+
* cannot be listed here without the list going stale the next time a compiler
|
|
45
|
+
* grows a key, so they are read off the `system` object the compiler produced:
|
|
46
|
+
* {@link compareEmittedSystem} takes the assembled block and asks what the
|
|
47
|
+
* schema does not declare. That is the same question, against the same schema,
|
|
48
|
+
* with the emitted set *observed* rather than derived.
|
|
39
49
|
*
|
|
40
50
|
* **The declared half is the consumer's, and arrives as data.** `defineSchema()`
|
|
41
51
|
* lives in the target system's `src/`, so the system publishes its field sets as
|
|
@@ -56,6 +66,8 @@ import fs from "node:fs";
|
|
|
56
66
|
import path from "node:path";
|
|
57
67
|
|
|
58
68
|
import { cachedSchemaPath, SCHEMA_ARTIFACT_FILE } from "./foreign-catalog.mjs";
|
|
69
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
70
|
+
import { systemData, systemDataPaths, undeclaredPaths } from "./system-block.mjs";
|
|
59
71
|
|
|
60
72
|
/**
|
|
61
73
|
* The artifact version this module reads.
|
|
@@ -239,6 +251,186 @@ export function compareFields({
|
|
|
239
251
|
return { undeclared, unemitted, skipped };
|
|
240
252
|
}
|
|
241
253
|
|
|
254
|
+
/**
|
|
255
|
+
* One `system` key a compiled document carries that its subtype does not
|
|
256
|
+
* declare.
|
|
257
|
+
*
|
|
258
|
+
* @typedef {object} EmissionFinding
|
|
259
|
+
* @property {string} type - The content type whose note produced the document.
|
|
260
|
+
* @property {string} subtype - The document subtype it compiled into.
|
|
261
|
+
* @property {string} documentType - `Item`, `Actor`, …
|
|
262
|
+
* @property {string} field - The undeclared path, dotted.
|
|
263
|
+
* @property {string} systemVersion - The version checked against.
|
|
264
|
+
* @property {"builder"|"compiler"} origin - What wrote it. See
|
|
265
|
+
* {@link emittedUndeclaredMessage}.
|
|
266
|
+
*/
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether a value is a mapping this walk descends into. Arrays are leaves: a
|
|
270
|
+
* schema declares an `ArrayField` as one path and says nothing about its
|
|
271
|
+
* indices.
|
|
272
|
+
*
|
|
273
|
+
* @param {unknown} value - The emitted value.
|
|
274
|
+
* @returns {boolean} Whether to walk into it.
|
|
275
|
+
*/
|
|
276
|
+
function isMapping(value) {
|
|
277
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Every declared path that has at least one declared path beneath it.
|
|
282
|
+
*
|
|
283
|
+
* The artifact does not describe every subtree it declares. A discriminated
|
|
284
|
+
* `TypedSchemaField` — SoHL's `strikeMode` — is published as the single path
|
|
285
|
+
* `strikeMode`, because its sub-schema is chosen by a discriminator at runtime
|
|
286
|
+
* and there is no one field list to publish; the compiled document stores it
|
|
287
|
+
* flat as `{ type, name, … }` all the same. Walking into it against a schema
|
|
288
|
+
* that says nothing about its contents reported all ten of a combat technique's
|
|
289
|
+
* stored keys as undeclared — ten findings, every one wrong, about a document
|
|
290
|
+
* that is correct.
|
|
291
|
+
*
|
|
292
|
+
* So a container is descended into only where the artifact actually enumerates
|
|
293
|
+
* something beneath it. That is the same stance {@link declaredFields} takes on
|
|
294
|
+
* a subtype the artifact does not name: what is not described is not checked,
|
|
295
|
+
* rather than guessed at.
|
|
296
|
+
*
|
|
297
|
+
* @param {ReadonlySet<string>} declared - Every path the subtype declares.
|
|
298
|
+
* @returns {Set<string>} The paths whose subtree the artifact describes.
|
|
299
|
+
*/
|
|
300
|
+
function enumeratedContainers(declared) {
|
|
301
|
+
const out = new Set();
|
|
302
|
+
for (const declaredPath of declared) {
|
|
303
|
+
const parts = declaredPath.split(".");
|
|
304
|
+
for (let i = 1; i < parts.length; i++) out.add(parts.slice(0, i).join("."));
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The paths in a compiled `system` block that the subtype does not declare.
|
|
311
|
+
*
|
|
312
|
+
* {@link module:engine/system-block.undeclaredPaths} answers this for *authored*
|
|
313
|
+
* data, where every path came from a human and every container is one a human
|
|
314
|
+
* typed. An emitted block is different in one way that matters: it contains
|
|
315
|
+
* whole subtrees the artifact declares as a single path and describes no
|
|
316
|
+
* further — see {@link enumeratedContainers} — so the walk stops there instead
|
|
317
|
+
* of reporting their contents.
|
|
318
|
+
*
|
|
319
|
+
* Reported at the shallowest undeclared path, as its sibling is: everything
|
|
320
|
+
* beneath an undeclared container is undeclared by construction, and listing it
|
|
321
|
+
* all buries the one key that is the problem.
|
|
322
|
+
*
|
|
323
|
+
* @param {Record<string, unknown>} data - The emitted `system` block.
|
|
324
|
+
* @param {ReadonlySet<string>} declared - Every path the subtype declares.
|
|
325
|
+
* @param {ReadonlySet<string>} enumerated - From {@link enumeratedContainers}.
|
|
326
|
+
* @param {string} [prefix] - Internal: the path so far.
|
|
327
|
+
* @returns {string[]} The undeclared paths, shallowest-first.
|
|
328
|
+
*/
|
|
329
|
+
function undeclaredEmittedPaths(data, declared, enumerated, prefix = "") {
|
|
330
|
+
const out = [];
|
|
331
|
+
if (!isMapping(data)) return out;
|
|
332
|
+
for (const [key, value] of Object.entries(data)) {
|
|
333
|
+
const emittedPath = prefix ? `${prefix}.${key}` : key;
|
|
334
|
+
if (!declared.has(emittedPath)) {
|
|
335
|
+
out.push(emittedPath);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!isMapping(value) || !Object.keys(value).length) continue;
|
|
339
|
+
if (!enumerated.has(emittedPath)) continue;
|
|
340
|
+
out.push(
|
|
341
|
+
...undeclaredEmittedPaths(
|
|
342
|
+
/** @type {Record<string, unknown>} */ (value),
|
|
343
|
+
declared,
|
|
344
|
+
enumerated,
|
|
345
|
+
emittedPath,
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* What a **compiled document** carries in `system`, against what the receiving
|
|
354
|
+
* subtype declares (#155).
|
|
355
|
+
*
|
|
356
|
+
* The third of the three checks, and the only one whose emitted set is
|
|
357
|
+
* *observed*. {@link compareFields} reads the `itemBuilders` declarations and
|
|
358
|
+
* {@link checkAuthoredSystemData} reads a note's `<system>.system`; between them
|
|
359
|
+
* they miss every key a compiler writes on its own initiative, which is not a
|
|
360
|
+
* residue — it is `shortcode`, `actionDefs`, `notes`, `docHtml` and `archetype`.
|
|
361
|
+
*
|
|
362
|
+
* **The keys come from the object the compiler built, after a JSON round trip.**
|
|
363
|
+
* That is exactly what the pack file receives, so a key whose value is
|
|
364
|
+
* `undefined` — dropped by `JSON.stringify`, never written, nothing for Foundry
|
|
365
|
+
* to discard — is correctly not a finding. Reading the assembled block is also
|
|
366
|
+
* the only derivation that cannot go stale: a compiler that grows a key is
|
|
367
|
+
* checked on the next build without anyone remembering to add it to a list.
|
|
368
|
+
*
|
|
369
|
+
* **Authored paths are left alone.** A note's own `<system>.system` is reported
|
|
370
|
+
* by {@link checkAuthoredSystemData}, which can point at the line the author
|
|
371
|
+
* wrote; reporting it again here would be the same defect twice, once without a
|
|
372
|
+
* position.
|
|
373
|
+
*
|
|
374
|
+
* **A subtree the artifact does not describe is not checked**, rather than
|
|
375
|
+
* reported wholesale — see {@link enumeratedContainers} for the
|
|
376
|
+
* `TypedSchemaField` case that makes the distinction load-bearing.
|
|
377
|
+
*
|
|
378
|
+
* @param {object} opts
|
|
379
|
+
* @param {object} opts.system - The `system` block the compiler assembled.
|
|
380
|
+
* @param {SchemaArtifact} opts.artifact - The receiving system's schemas.
|
|
381
|
+
* @param {string} opts.documentType - `Item`, `Actor`, …
|
|
382
|
+
* @param {string} opts.subtype - The document subtype being emitted.
|
|
383
|
+
* @param {string} opts.type - The content type whose note produced it, for the
|
|
384
|
+
* message.
|
|
385
|
+
* @param {readonly {to?: string}[]} [opts.fields] - The type's field
|
|
386
|
+
* declaration, which decides a finding's {@link EmissionFinding.origin}.
|
|
387
|
+
* @param {ReadonlySet<string>} [opts.authored] - Paths the note authored, left
|
|
388
|
+
* to the note-side check.
|
|
389
|
+
* @returns {EmissionFinding[]} One per undeclared path, shallowest-first.
|
|
390
|
+
*/
|
|
391
|
+
export function compareEmittedSystem({
|
|
392
|
+
system,
|
|
393
|
+
artifact,
|
|
394
|
+
documentType,
|
|
395
|
+
subtype,
|
|
396
|
+
type,
|
|
397
|
+
fields,
|
|
398
|
+
authored = new Set(),
|
|
399
|
+
}) {
|
|
400
|
+
if (artifact?.version !== SCHEMA_ARTIFACT_VERSION) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`package-build: schema artifact version ${artifact?.version ?? "(absent)"}, ` +
|
|
403
|
+
`expected ${SCHEMA_ARTIFACT_VERSION}. A schema read under the wrong ` +
|
|
404
|
+
`shape would report confidently in both directions, so the check ` +
|
|
405
|
+
`stops rather than resolving anyway.`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const declared = declaredFields(artifact, documentType, subtype);
|
|
410
|
+
// Not a finding, for the same reason `compareFields` skips one: a subtype
|
|
411
|
+
// the artifact says nothing about is a routing question (#79), not a field
|
|
412
|
+
// one, and guessing at it would report every key on the document.
|
|
413
|
+
if (!declared) return [];
|
|
414
|
+
|
|
415
|
+
// What the pack file receives. `undefined` is gone after this, which is the
|
|
416
|
+
// point: a key that never reaches the JSON is not an emitted key.
|
|
417
|
+
const emitted = JSON.parse(JSON.stringify(system ?? {}));
|
|
418
|
+
const byField = emittedFields(fields ?? []);
|
|
419
|
+
|
|
420
|
+
return undeclaredEmittedPaths(emitted, declared.all, enumeratedContainers(declared.all))
|
|
421
|
+
.filter((field) => !authored.has(field))
|
|
422
|
+
.map((field) => ({
|
|
423
|
+
type,
|
|
424
|
+
subtype,
|
|
425
|
+
documentType,
|
|
426
|
+
field,
|
|
427
|
+
systemVersion: artifact.systemVersion,
|
|
428
|
+
origin: /** @type {"builder"|"compiler"} */ (
|
|
429
|
+
byField.has(field) ? "builder" : "compiler"
|
|
430
|
+
),
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
|
|
242
434
|
/**
|
|
243
435
|
* The published schema this build should check itself against, or `null`.
|
|
244
436
|
*
|
|
@@ -327,3 +519,155 @@ export function unemittedMessage(finding) {
|
|
|
327
519
|
`field's initial value rather than an authored one`
|
|
328
520
|
);
|
|
329
521
|
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* What an author is told about an emitted key the target system does not
|
|
525
|
+
* define — in the terms of whoever can actually fix it.
|
|
526
|
+
*
|
|
527
|
+
* The failure is identical in both cases and the remedies are not, which is
|
|
528
|
+
* why the two are told apart at all:
|
|
529
|
+
*
|
|
530
|
+
* | origin | who writes it | what fixes it |
|
|
531
|
+
* | --- | --- | --- |
|
|
532
|
+
* | `builder` | a `fields:` entry in this repository's `itemBuilders` | change the field's `to`, or get the system to declare it |
|
|
533
|
+
* | `compiler` | this package, on every document of the subtype | **nothing here** — the system must declare it, or this package must be pinned to a build that does not write it |
|
|
534
|
+
*
|
|
535
|
+
* A `compiler` finding is the one worth spelling out, because the obvious first
|
|
536
|
+
* move — go and look for the field in `itemBuilders` — leads nowhere: there is
|
|
537
|
+
* no declaration to correct. It means the build is running ahead of the system
|
|
538
|
+
* it compiles for, and the version named in the message is what says so.
|
|
539
|
+
*
|
|
540
|
+
* @param {EmissionFinding} finding - One entry from
|
|
541
|
+
* {@link compareEmittedSystem}.
|
|
542
|
+
* @returns {string} The message.
|
|
543
|
+
*/
|
|
544
|
+
export function emittedUndeclaredMessage(finding) {
|
|
545
|
+
if (finding.origin === "builder") return undeclaredMessage(finding);
|
|
546
|
+
return (
|
|
547
|
+
`the compiler writes \`system.${finding.field}\` into every ` +
|
|
548
|
+
`${finding.documentType} of subtype "${finding.subtype}", and no field ` +
|
|
549
|
+
`declaration names it — ${finding.documentType} subtype ` +
|
|
550
|
+
`"${finding.subtype}" does not define it at ${finding.systemVersion}, and ` +
|
|
551
|
+
`Foundry discards an unknown \`system\` key when the document is ` +
|
|
552
|
+
`constructed, without a warning, so the value is lost at load while the ` +
|
|
553
|
+
`build reports success. No \`itemBuilders\` change fixes this: declare ` +
|
|
554
|
+
`the field in the receiving system, or hold this package at a build that ` +
|
|
555
|
+
`does not write it`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** One resolved schema artifact per resolved configuration. */
|
|
560
|
+
const artifacts = new WeakMap();
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* {@link resolveSchemaArtifact}, read once per configuration.
|
|
564
|
+
*
|
|
565
|
+
* The per-note check below runs thousands of times in a build and the artifact
|
|
566
|
+
* never changes inside one, so reading and parsing it per note would be a
|
|
567
|
+
* megabyte of JSON per hundred documents for an answer that is already known.
|
|
568
|
+
*
|
|
569
|
+
* @param {object} config - The resolved build configuration.
|
|
570
|
+
* @returns {{artifact: SchemaArtifact, source: string}|null} The schema.
|
|
571
|
+
*/
|
|
572
|
+
function schemaFor(config) {
|
|
573
|
+
if (!artifacts.has(config)) artifacts.set(config, resolveSchemaArtifact(config));
|
|
574
|
+
return artifacts.get(config);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* What a note authors under `<system>.system`, against what the receiving
|
|
579
|
+
* subtype declares (#58).
|
|
580
|
+
*
|
|
581
|
+
* The **note-side** half of the check `compareFields` performs on the
|
|
582
|
+
* declarations. A field list is checked once for the whole build because it is
|
|
583
|
+
* the same for every document; an authored `system` block is a property of one
|
|
584
|
+
* note, so it is checked where that note is compiled and reported against that
|
|
585
|
+
* note's file.
|
|
586
|
+
*
|
|
587
|
+
* It is the same failure either way, and the reason both halves exist: Foundry
|
|
588
|
+
* discards an unknown `system` key at construction and says nothing, so a
|
|
589
|
+
* mistyped path is lost at load while the build reports success.
|
|
590
|
+
*
|
|
591
|
+
* **Silent where there is nothing to check against.** A module pinning a system
|
|
592
|
+
* version released before the artifact existed, or a subtype the artifact does
|
|
593
|
+
* not name, produces no findings — the same stance `compareFields` takes, where
|
|
594
|
+
* an unknown subtype is a routing question rather than a field one. The
|
|
595
|
+
* whole-build check in `content-build lint` is where a missing artifact is said
|
|
596
|
+
* out loud, once, instead of per note.
|
|
597
|
+
*
|
|
598
|
+
* @param {object} fm - The note's frontmatter.
|
|
599
|
+
* @param {object} opts
|
|
600
|
+
* @param {string} opts.block - The system block to read, e.g. `"sohl"`.
|
|
601
|
+
* @param {string} opts.documentType - `Item`, `Actor`, …
|
|
602
|
+
* @param {string} opts.subType - The document subtype the note compiles into.
|
|
603
|
+
* @param {object} [opts.config] - The resolved build configuration.
|
|
604
|
+
* @returns {{path: string, message: string}[]} One finding per undeclared path,
|
|
605
|
+
* shallowest-first.
|
|
606
|
+
*/
|
|
607
|
+
export function checkAuthoredSystemData(
|
|
608
|
+
fm,
|
|
609
|
+
{ block, documentType, subType, config = loadPackConfig() },
|
|
610
|
+
) {
|
|
611
|
+
const data = systemData(fm, block);
|
|
612
|
+
if (!Object.keys(data).length) return [];
|
|
613
|
+
|
|
614
|
+
const schema = schemaFor(config);
|
|
615
|
+
if (!schema) return [];
|
|
616
|
+
const declared = declaredFields(schema.artifact, documentType, subType);
|
|
617
|
+
if (!declared) return [];
|
|
618
|
+
|
|
619
|
+
return undeclaredPaths(data, declared.all).map((path) => ({
|
|
620
|
+
path,
|
|
621
|
+
message:
|
|
622
|
+
`\`${block}.system.${path}\` is not a field ${documentType} subtype ` +
|
|
623
|
+
`"${subType}" declares at ${schema.artifact.systemVersion} — Foundry ` +
|
|
624
|
+
`discards an unknown \`system\` key when the document is constructed, ` +
|
|
625
|
+
`without a warning, so the value is lost at load while the build ` +
|
|
626
|
+
`reports success`,
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The `system` block a compiler just assembled, against what the receiving
|
|
632
|
+
* subtype declares (#155).
|
|
633
|
+
*
|
|
634
|
+
* The build-time face of {@link compareEmittedSystem}: it resolves the schema
|
|
635
|
+
* the way every other check here does — the system's own committed artifact, or
|
|
636
|
+
* the cached one from the release a module pins — and attaches the message a
|
|
637
|
+
* reader sees.
|
|
638
|
+
*
|
|
639
|
+
* **Silent where there is nothing to check against**, exactly as its two
|
|
640
|
+
* siblings are: a module pinning a system version released before the artifact
|
|
641
|
+
* existed, or a subtype the artifact does not name, produces no findings.
|
|
642
|
+
* `content-build lint` is where a missing artifact is said out loud, once.
|
|
643
|
+
*
|
|
644
|
+
* @param {object} system - The `system` block the compiler produced.
|
|
645
|
+
* @param {object} opts
|
|
646
|
+
* @param {object} opts.fm - The note's frontmatter, for the authored paths this
|
|
647
|
+
* check leaves to {@link checkAuthoredSystemData}.
|
|
648
|
+
* @param {string} opts.block - The system block to read, e.g. `"sohl"`.
|
|
649
|
+
* @param {string} opts.documentType - `Item`, `Actor`, …
|
|
650
|
+
* @param {string} opts.subType - The document subtype the note compiles into.
|
|
651
|
+
* @param {string} opts.type - The note's content type, for the message.
|
|
652
|
+
* @param {readonly {to?: string}[]} [opts.fields] - The type's field
|
|
653
|
+
* declaration, which decides each finding's origin.
|
|
654
|
+
* @param {object} [opts.config] - The resolved build configuration.
|
|
655
|
+
* @returns {(EmissionFinding & {message: string})[]} One per undeclared path.
|
|
656
|
+
*/
|
|
657
|
+
export function checkEmittedSystemData(
|
|
658
|
+
system,
|
|
659
|
+
{ fm, block, documentType, subType, type, fields, config = loadPackConfig() },
|
|
660
|
+
) {
|
|
661
|
+
const schema = schemaFor(config);
|
|
662
|
+
if (!schema) return [];
|
|
663
|
+
|
|
664
|
+
return compareEmittedSystem({
|
|
665
|
+
system,
|
|
666
|
+
artifact: schema.artifact,
|
|
667
|
+
documentType,
|
|
668
|
+
subtype: subType,
|
|
669
|
+
type,
|
|
670
|
+
fields,
|
|
671
|
+
authored: new Set(systemDataPaths(systemData(fm, block))),
|
|
672
|
+
}).map((finding) => ({ ...finding, message: emittedUndeclaredMessage(finding) }));
|
|
673
|
+
}
|
package/engine/site-build.mjs
CHANGED
|
@@ -223,7 +223,7 @@ export function collectTreePages(tree, ctx) {
|
|
|
223
223
|
const rel = path.relative(tree.from, file).replace(/\\/g, "/");
|
|
224
224
|
const base = path.basename(rel);
|
|
225
225
|
const isReadme = base.toLowerCase() === "readme.md";
|
|
226
|
-
const sec = fm.
|
|
226
|
+
const sec = fm.subType ?? tree.section;
|
|
227
227
|
const h1 = /^#\s+(.+?)\s*$/m.exec(body);
|
|
228
228
|
const h1Title = h1 ? h1[1].replace(/\{@link\s+[^}]*\}/g, "").trim() : null;
|
|
229
229
|
const name = fm.name?.full ?? fm.title ?? h1Title ?? path.basename(base, ".md");
|
package/engine/site-index.mjs
CHANGED
|
@@ -54,6 +54,9 @@ import path from "node:path";
|
|
|
54
54
|
import { canonicalKey, readCanonicalKey } from "./kb-manifest.mjs";
|
|
55
55
|
import { hasDocEntry } from "./item-docs.mjs";
|
|
56
56
|
import { contentPackage } from "./content-package.mjs";
|
|
57
|
+
// The alias namespace: what a page may be called, and what happens when two
|
|
58
|
+
// pages of one type claim one name (#131).
|
|
59
|
+
import { aliasesOf, indexAliases } from "./alias-index.mjs";
|
|
57
60
|
|
|
58
61
|
/**
|
|
59
62
|
* One page the site will publish, as the index needs to see it.
|
|
@@ -180,11 +183,17 @@ function mergeForeign(index, foreignIndex) {
|
|
|
180
183
|
export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
|
|
181
184
|
const index = new Map();
|
|
182
185
|
const ambiguous = new Set();
|
|
183
|
-
|
|
184
|
-
const
|
|
186
|
+
/** One entry per page, fed to {@link indexAliases} in a pass of its own. */
|
|
187
|
+
const aliasEntries = [];
|
|
185
188
|
const contentTypes = new Set();
|
|
186
189
|
const sections = new Set();
|
|
187
190
|
const refIndex = new Map();
|
|
191
|
+
// Every package an address may name: this build's own, plus every one a
|
|
192
|
+
// vendored manifest speaks for. Without it `readQualifier` cannot see the
|
|
193
|
+
// leading package segment of a canonical address, and `kethira-place-x`
|
|
194
|
+
// reads as the unknown type `kethira` (#131).
|
|
195
|
+
const ownPackage = contentPackage();
|
|
196
|
+
const packages = new Set(ownPackage ? [ownPackage] : []);
|
|
188
197
|
|
|
189
198
|
// `section/slug` is unique by construction; the rest are fallbacks.
|
|
190
199
|
for (const e of entries) {
|
|
@@ -203,6 +212,7 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
|
|
|
203
212
|
// without it the link reads as prose and silently loses its href.
|
|
204
213
|
for (const value of foreignIndex.values()) {
|
|
205
214
|
if (value.type) contentTypes.add(value.type);
|
|
215
|
+
if (value.package) packages.add(value.package);
|
|
206
216
|
}
|
|
207
217
|
|
|
208
218
|
// Merged *before* the local type-scoped pass below, so a local page always
|
|
@@ -239,7 +249,8 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
|
|
|
239
249
|
// The page's package is the configured one — the site collection
|
|
240
250
|
// resolves it and records it as `pkg`. Never read out of
|
|
241
251
|
// frontmatter: `package:` is retired (#56).
|
|
242
|
-
index.set(canonicalKey(e.pkg ??
|
|
252
|
+
index.set(canonicalKey(e.pkg ?? ownPackage, type, shortcode), value);
|
|
253
|
+
if (e.pkg) packages.add(e.pkg);
|
|
243
254
|
// In Foundry an item and its documentation are two documents, so
|
|
244
255
|
// `skill/wpnc` and `docskill/wpnc` are two UUIDs (#1362). Here the
|
|
245
256
|
// item note renders as one page which *is* its documentation, so
|
|
@@ -253,33 +264,38 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
|
|
|
253
264
|
}
|
|
254
265
|
}
|
|
255
266
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (typeCollide.has(key)) continue;
|
|
266
|
-
const cur = typeAlias.get(key);
|
|
267
|
-
if (cur && cur.url !== value.url) {
|
|
268
|
-
typeAlias.delete(key);
|
|
269
|
-
typeCollide.add(key);
|
|
270
|
-
} else if (!cur) {
|
|
271
|
-
typeAlias.set(key, value);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
267
|
+
// The alias sources are the shared ones — `aliases`, `name.aliases`
|
|
268
|
+
// and `name.full`, and deliberately **not** the filename (#131) — plus
|
|
269
|
+
// this page's display name, which is what `name.full` becomes on the
|
|
270
|
+
// site and is carried here already resolved.
|
|
271
|
+
aliasEntries.push({
|
|
272
|
+
type,
|
|
273
|
+
aliases: [...aliasesOf(e.fm), e.name],
|
|
274
|
+
value,
|
|
275
|
+
});
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
// Built in one pass at the end, by the shared rule, so a collision is a
|
|
279
|
+
// reportable fact rather than a silently deleted key. Two pages of one
|
|
280
|
+
// type sharing an alias resolve to neither, and `aliasCollisions` names
|
|
281
|
+
// every claimant — the citing page is innocent (#13, #131).
|
|
282
|
+
const {
|
|
283
|
+
byKey: typeAlias,
|
|
284
|
+
collisions: aliasCollisions,
|
|
285
|
+
claims: aliasClaims,
|
|
286
|
+
} = indexAliases(aliasEntries, { same: (a, b) => a.url === b.url });
|
|
287
|
+
const typeCollide = new Set(aliasCollisions.map((c) => c.key));
|
|
288
|
+
|
|
276
289
|
return {
|
|
277
290
|
index,
|
|
278
291
|
ambiguous,
|
|
279
292
|
typeAlias,
|
|
280
293
|
typeCollide,
|
|
294
|
+
aliasCollisions,
|
|
295
|
+
aliasClaims,
|
|
281
296
|
contentTypes,
|
|
282
297
|
sections,
|
|
298
|
+
packages,
|
|
283
299
|
refIndex,
|
|
284
300
|
conflicts,
|
|
285
301
|
};
|
|
@@ -320,6 +336,7 @@ export function wikiContext(
|
|
|
320
336
|
typeAlias: built.typeAlias,
|
|
321
337
|
typeCollide: built.typeCollide,
|
|
322
338
|
contentTypes: built.contentTypes,
|
|
339
|
+
packages: built.packages,
|
|
323
340
|
type,
|
|
324
341
|
errors,
|
|
325
342
|
src,
|