@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
package/engine/pack-config.mjs
CHANGED
|
@@ -81,11 +81,7 @@ import { createRequire } from "node:module";
|
|
|
81
81
|
import YAML from "yaml";
|
|
82
82
|
|
|
83
83
|
import { defineConfig, DERIVED_SYSTEM_VERSION } from "../content-config.mjs";
|
|
84
|
-
import {
|
|
85
|
-
formatDiagnostic,
|
|
86
|
-
positionOfYamlPath,
|
|
87
|
-
yamlKeyPath,
|
|
88
|
-
} from "./diagnostics.mjs";
|
|
84
|
+
import { formatDiagnostic, positionOfYamlPath, yamlKeyPath } from "./diagnostics.mjs";
|
|
89
85
|
|
|
90
86
|
/** The stem every consuming repository declares its build under. */
|
|
91
87
|
export const CONFIG_BASENAME = "package-build.config";
|
|
@@ -130,9 +126,9 @@ export const CONFIG_FILENAMES = Object.freeze([
|
|
|
130
126
|
export function findConfigFile(from) {
|
|
131
127
|
let dir = path.resolve(from);
|
|
132
128
|
for (;;) {
|
|
133
|
-
const found = CONFIG_FILENAMES.map((name) =>
|
|
134
|
-
|
|
135
|
-
)
|
|
129
|
+
const found = CONFIG_FILENAMES.map((name) => path.join(dir, name)).filter((candidate) =>
|
|
130
|
+
fs.existsSync(candidate),
|
|
131
|
+
);
|
|
136
132
|
if (found.length > 1) {
|
|
137
133
|
throw new Error(
|
|
138
134
|
`package-build: ${dir} holds more than one configuration ` +
|
|
@@ -275,11 +271,9 @@ function shippedSystemVersion(rootDir, input) {
|
|
|
275
271
|
return pkg.version;
|
|
276
272
|
}
|
|
277
273
|
|
|
278
|
-
const systemId = /** @type {Record<string, unknown>} */ (input.stats ?? {})
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
input.relationships ?? {}
|
|
282
|
-
).systems;
|
|
274
|
+
const systemId = /** @type {Record<string, unknown>} */ (input.stats ?? {}).systemId;
|
|
275
|
+
const declaredSystems = /** @type {Record<string, unknown>} */ (input.relationships ?? {})
|
|
276
|
+
.systems;
|
|
283
277
|
|
|
284
278
|
// The `systems:` block declares without requiring (#48), so it is consulted
|
|
285
279
|
// first: a package that has adopted it needs no relationship, and one that
|
|
@@ -288,10 +282,9 @@ function shippedSystemVersion(rootDir, input) {
|
|
|
288
282
|
// otherwise a single declared system is unambiguous. With several and no
|
|
289
283
|
// gate, there is no package-wide answer — each pack carries its own, and
|
|
290
284
|
// {@link statsForPack} is what reads it.
|
|
291
|
-
const systemsBlock =
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
);
|
|
285
|
+
const systemsBlock = /** @type {Record<string, {compatibility?: {verified?: string}}>} */ (
|
|
286
|
+
input.systems ?? {}
|
|
287
|
+
);
|
|
295
288
|
const systemIds = Object.keys(systemsBlock);
|
|
296
289
|
if (systemIds.length) {
|
|
297
290
|
const chosen =
|
|
@@ -300,8 +293,7 @@ function shippedSystemVersion(rootDir, input) {
|
|
|
300
293
|
: null;
|
|
301
294
|
if (chosen) {
|
|
302
295
|
const verified = systemsBlock[chosen]?.compatibility?.verified;
|
|
303
|
-
if (typeof verified === "string" && verified.length)
|
|
304
|
-
return verified;
|
|
296
|
+
if (typeof verified === "string" && verified.length) return verified;
|
|
305
297
|
}
|
|
306
298
|
// Several declared and none required: the package-wide value is
|
|
307
299
|
// deliberately absent rather than one of them picked arbitrarily.
|
|
@@ -323,11 +315,9 @@ function shippedSystemVersion(rootDir, input) {
|
|
|
323
315
|
|
|
324
316
|
const systems =
|
|
325
317
|
/** @type {{id?: string, compatibility?: {verified?: string}}[]} */ (
|
|
326
|
-
/** @type {Record<string, unknown>} */ (input.relationships ?? {})
|
|
327
|
-
.systems
|
|
318
|
+
/** @type {Record<string, unknown>} */ (input.relationships ?? {}).systems
|
|
328
319
|
) ?? [];
|
|
329
|
-
const relationship =
|
|
330
|
-
systems.find((entry) => entry?.id === systemId) ?? systems[0];
|
|
320
|
+
const relationship = systems.find((entry) => entry?.id === systemId) ?? systems[0];
|
|
331
321
|
const verified = relationship?.compatibility?.verified;
|
|
332
322
|
|
|
333
323
|
if (typeof verified !== "string" || verified.length === 0) {
|
|
@@ -413,11 +403,7 @@ export function locateConfigError(err, configPath) {
|
|
|
413
403
|
err
|
|
414
404
|
);
|
|
415
405
|
if (!(err instanceof Error)) return err;
|
|
416
|
-
if (
|
|
417
|
-
failure.located ||
|
|
418
|
-
typeof failure.field !== "string" ||
|
|
419
|
-
!failure.field
|
|
420
|
-
) {
|
|
406
|
+
if (failure.located || typeof failure.field !== "string" || !failure.field) {
|
|
421
407
|
return err;
|
|
422
408
|
}
|
|
423
409
|
if (!configPath) return err;
|
|
@@ -445,9 +431,10 @@ export function locateConfigError(err, configPath) {
|
|
|
445
431
|
* - **`rootDir`** is the configuration's own directory, always. A data file
|
|
446
432
|
* cannot write `import.meta.dirname`, and any absolute path it wrote instead
|
|
447
433
|
* would be one machine's — so authoring it is rejected rather than honoured.
|
|
448
|
-
* - **`itemBuilders`** is a *name* (`sohl`),
|
|
449
|
-
*
|
|
450
|
-
*
|
|
434
|
+
* - **`itemBuilders`** is a *name* (`sohl`) — or a list of names, for a tree
|
|
435
|
+
* feeding more than one system (#58) — resolved against the built-in
|
|
436
|
+
* registries. A registry's name is the system it belongs to. A registry of a
|
|
437
|
+
* consumer's own is code, and code goes in an `.mjs` configuration.
|
|
451
438
|
* - **`stats.systemVersion`** is derived from the adjacent `package.json` when
|
|
452
439
|
* the configuration does not state it. Stating it is still allowed: a
|
|
453
440
|
* repository shipping content *for* another package (a module declaring
|
|
@@ -496,26 +483,42 @@ export function configFromData(data, configPath) {
|
|
|
496
483
|
input.foundryPackage = foundryPackageId(rootDir);
|
|
497
484
|
|
|
498
485
|
if (input.itemBuilders !== undefined) {
|
|
499
|
-
const
|
|
486
|
+
const declared = input.itemBuilders;
|
|
500
487
|
const known = Object.keys(ITEM_BUILDER_REGISTRIES).join(", ");
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
);
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
488
|
+
// One name or several. A repository feeding two systems needs both
|
|
489
|
+
// vocabularies, and one registry can only carry one (#58); the scalar
|
|
490
|
+
// form every existing configuration uses is the one-element case and
|
|
491
|
+
// means exactly what it always did.
|
|
492
|
+
const names = Array.isArray(declared) ? declared : [declared];
|
|
493
|
+
const resolved = names.map((named, index) => {
|
|
494
|
+
const where = Array.isArray(declared) ? `itemBuilders[${index}]` : "itemBuilders";
|
|
495
|
+
if (typeof named !== "string") {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`package-build: ${configPath} must name its \`${where}\` ` +
|
|
498
|
+
`registry as a string — the registry is code, and data ` +
|
|
499
|
+
`cannot carry it. Known registries: ${known}; a registry ` +
|
|
500
|
+
`of your own goes in ${CONFIG_BASENAME}.mjs.`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
const load = ITEM_BUILDER_REGISTRIES[named];
|
|
504
|
+
if (!load) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`package-build: ${configPath} names the \`${where}\` ` +
|
|
507
|
+
`registry "${named}", which this package does not ship. ` +
|
|
508
|
+
`Known registries: ${known}. To supply your own, declare ` +
|
|
509
|
+
`it in ${CONFIG_BASENAME}.mjs.`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
// A shipped registry's *name* is the system it belongs to, which is
|
|
513
|
+
// what lets a data configuration declare a set without carrying the
|
|
514
|
+
// system id a second time.
|
|
515
|
+
return { system: named, builders: load() };
|
|
516
|
+
});
|
|
517
|
+
// The scalar form stays the flat, system-less registry it has always
|
|
518
|
+
// resolved to. Wrapping it as a one-entry set would be tidier and would
|
|
519
|
+
// change what every existing configuration means — `itemBuildersBySystem`
|
|
520
|
+
// would gain an entry the consumer never declared.
|
|
521
|
+
input.itemBuilders = Array.isArray(declared) ? resolved : resolved[0].builders;
|
|
519
522
|
}
|
|
520
523
|
|
|
521
524
|
const stats = input.stats;
|
|
@@ -562,10 +565,7 @@ function loadCodeConfig(configPath) {
|
|
|
562
565
|
// arrive here. There is no YAML to locate into, but the file is known —
|
|
563
566
|
// `locateConfigError` names it and drops the line.
|
|
564
567
|
locateConfigError(err, configPath);
|
|
565
|
-
if (
|
|
566
|
-
/** @type {{ code?: string }} */ (err)?.code ===
|
|
567
|
-
"ERR_REQUIRE_ASYNC_MODULE"
|
|
568
|
-
) {
|
|
568
|
+
if (/** @type {{ code?: string }} */ (err)?.code === "ERR_REQUIRE_ASYNC_MODULE") {
|
|
569
569
|
throw new Error(
|
|
570
570
|
`package-build: ${configPath} (or something it imports) uses ` +
|
|
571
571
|
`top-level await, which the configuration cannot: it is ` +
|
|
@@ -603,8 +603,7 @@ export function loadPackConfig() {
|
|
|
603
603
|
if (loaded) return loaded;
|
|
604
604
|
|
|
605
605
|
const explicit = process.env.PACKAGE_BUILD_CONFIG;
|
|
606
|
-
const configPath =
|
|
607
|
-
explicit ? path.resolve(explicit) : findConfigFile(import.meta.dirname);
|
|
606
|
+
const configPath = explicit ? path.resolve(explicit) : findConfigFile(import.meta.dirname);
|
|
608
607
|
|
|
609
608
|
if (!configPath || !fs.existsSync(configPath)) {
|
|
610
609
|
throw new Error(
|
|
@@ -621,10 +620,7 @@ export function loadPackConfig() {
|
|
|
621
620
|
loaded =
|
|
622
621
|
configPath.endsWith(".mjs") ?
|
|
623
622
|
loadCodeConfig(configPath)
|
|
624
|
-
: configFromData(
|
|
625
|
-
YAML.parse(fs.readFileSync(configPath, "utf8")),
|
|
626
|
-
configPath,
|
|
627
|
-
);
|
|
623
|
+
: configFromData(YAML.parse(fs.readFileSync(configPath, "utf8")), configPath);
|
|
628
624
|
loadedFrom = configPath;
|
|
629
625
|
return loaded;
|
|
630
626
|
}
|
package/engine/pack-router.mjs
CHANGED
|
@@ -47,12 +47,18 @@
|
|
|
47
47
|
* - A note's **derived** documents are routed by the default of *their* type,
|
|
48
48
|
* not by the note's declaration: an item note's prose compiles into a
|
|
49
49
|
* JournalEntry, and `pack:` names where the *item* goes.
|
|
50
|
+
* - A note feeding **more than one system** declares `pack:` inside the block
|
|
51
|
+
* that differs (#58). `pack` needed no new mechanism for that: it is an
|
|
52
|
+
* ordinary shared property, so `<system>.pack` overrides the top-level one
|
|
53
|
+
* for that system's document and leaves it standing for every other. A note
|
|
54
|
+
* wanting one pack for both says it once at the top.
|
|
50
55
|
*
|
|
51
56
|
* @module
|
|
52
57
|
*/
|
|
53
58
|
|
|
54
59
|
import { packForType } from "./ids.mjs";
|
|
55
60
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
61
|
+
import { blockProperty } from "./system-block.mjs";
|
|
56
62
|
|
|
57
63
|
/**
|
|
58
64
|
* A note that cannot be routed to a pack. Thrown rather than returned so no
|
|
@@ -84,8 +90,8 @@ export const PACK_FIELD = "pack";
|
|
|
84
90
|
*
|
|
85
91
|
* @param {readonly object[]} packs - The resolved `packs` list from
|
|
86
92
|
* `defineConfig`.
|
|
87
|
-
* @returns {{resolve: (fm: object, docType: string) => string,
|
|
88
|
-
* resolveOrNull: (fm: object, docType: string) => string|undefined,
|
|
93
|
+
* @returns {{resolve: (fm: object, docType: string, system?: string) => string,
|
|
94
|
+
* resolveOrNull: (fm: object, docType: string, system?: string) => string|undefined,
|
|
89
95
|
* packsOfType: (docType: string) => string[],
|
|
90
96
|
* defaultOf: (docType: string) => string|undefined}} The router.
|
|
91
97
|
*/
|
|
@@ -117,19 +123,22 @@ export function createPackRouter(packs) {
|
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
/** @param {object} fm */
|
|
120
|
-
const noteLabel = (fm) =>
|
|
121
|
-
fm?.name?.full ?? fm?.shortcode ?? fm?.id ?? "a note";
|
|
126
|
+
const noteLabel = (fm) => fm?.name?.full ?? fm?.shortcode ?? fm?.id ?? "a note";
|
|
122
127
|
|
|
123
128
|
/**
|
|
124
129
|
* The pack one pass should write this note's document to.
|
|
125
130
|
*
|
|
126
131
|
* @param {object} fm - The note's frontmatter.
|
|
127
132
|
* @param {string} docType - The document type the calling pass writes.
|
|
133
|
+
* @param {string} [system] - The system whose document is being routed. Its
|
|
134
|
+
* block's `pack:` wins over the shared one; without it only the shared
|
|
135
|
+
* declaration is read, which is every single-system build.
|
|
128
136
|
* @returns {string} The pack name.
|
|
129
137
|
* @throws {PackRoutingError} When the note routes nowhere.
|
|
130
138
|
*/
|
|
131
|
-
function resolve(fm, docType) {
|
|
132
|
-
const declared =
|
|
139
|
+
function resolve(fm, docType, system) {
|
|
140
|
+
const declared =
|
|
141
|
+
system === undefined ? fm?.[PACK_FIELD] : blockProperty(fm, system, PACK_FIELD);
|
|
133
142
|
// The declaration names where the note's *own* document goes. A pass
|
|
134
143
|
// writing a document derived from it — an item's prose becoming a
|
|
135
144
|
// JournalEntry — is not what the author was addressing.
|
|
@@ -190,11 +199,12 @@ export function createPackRouter(packs) {
|
|
|
190
199
|
*
|
|
191
200
|
* @param {object} fm - The note's frontmatter.
|
|
192
201
|
* @param {string} docType - The document type being addressed.
|
|
202
|
+
* @param {string} [system] - The system whose document is addressed.
|
|
193
203
|
* @returns {string|undefined} The pack name, or `undefined`.
|
|
194
204
|
*/
|
|
195
|
-
resolveOrNull(fm, docType) {
|
|
205
|
+
resolveOrNull(fm, docType, system) {
|
|
196
206
|
try {
|
|
197
|
-
return resolve(fm, docType);
|
|
207
|
+
return resolve(fm, docType, system);
|
|
198
208
|
} catch {
|
|
199
209
|
return undefined;
|
|
200
210
|
}
|
package/engine/prose-config.mjs
CHANGED
|
@@ -46,7 +46,25 @@
|
|
|
46
46
|
* @type {Readonly<object>}
|
|
47
47
|
*/
|
|
48
48
|
export const PRETTIER_BASE = Object.freeze({
|
|
49
|
-
|
|
49
|
+
// 100, not the conventional 80. The usual argument for 80 is reading
|
|
50
|
+
// measure, and it does not apply here: `proseWrap` is left at Prettier's
|
|
51
|
+
// default of `preserve`, so authored prose is never reflowed at all — the
|
|
52
|
+
// notes in these trees run to a p90 of 378 characters a line and are
|
|
53
|
+
// untouched by this number. What it actually governs is TypeScript and the
|
|
54
|
+
// YAML of a note's frontmatter, and both were measurably cramped at 80.
|
|
55
|
+
//
|
|
56
|
+
// Measured before changing it, across a third of the SoHL source: at 80,
|
|
57
|
+
// Prettier *fails* to honour the limit on 1,399 lines — long string
|
|
58
|
+
// literals, `@src/…` specifiers, generic signatures — so those lines are
|
|
59
|
+
// over-width anyway and their surroundings were broken up for nothing. At
|
|
60
|
+
// 100 that falls to 75, and the file is 4.9% shorter. Past 100 the returns
|
|
61
|
+
// flatten (120 buys another 2.6%).
|
|
62
|
+
//
|
|
63
|
+
// The same knee appears in content: an item entry written in flow style —
|
|
64
|
+
// `{ shortcode: X, type: skill, name: …, system: { … } }` — typically lands
|
|
65
|
+
// in the low 90s, so 90.8% of 318,030 entries fit on one line at 80 and
|
|
66
|
+
// 95.3% at 100, with almost nothing gained in between.
|
|
67
|
+
printWidth: 100,
|
|
50
68
|
tabWidth: 4,
|
|
51
69
|
useTabs: false,
|
|
52
70
|
semi: true,
|
|
@@ -113,9 +131,7 @@ export const PRETTIER_CONFIG = Object.freeze({
|
|
|
113
131
|
* `overrides`: passing that inline is what silently did nothing (#76).
|
|
114
132
|
*/
|
|
115
133
|
export function sharedPrettierOptionsFor(file) {
|
|
116
|
-
return /\.md$/i.test(file) ?
|
|
117
|
-
{ ...PRETTIER_BASE, ...PRETTIER_MARKDOWN }
|
|
118
|
-
: { ...PRETTIER_BASE };
|
|
134
|
+
return /\.md$/i.test(file) ? { ...PRETTIER_BASE, ...PRETTIER_MARKDOWN } : { ...PRETTIER_BASE };
|
|
119
135
|
}
|
|
120
136
|
|
|
121
137
|
/**
|
package/engine/prose-lint.mjs
CHANGED
|
@@ -61,6 +61,23 @@ const NEVER_WALK = Object.freeze(new Set([".git", "node_modules"]));
|
|
|
61
61
|
*/
|
|
62
62
|
const IGNORE_FILES = Object.freeze([".gitignore", ".prettierignore"]);
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* How many times `--write` will format one file looking for a fixpoint.
|
|
66
|
+
*
|
|
67
|
+
* `format` is *assumed* idempotent and is not guaranteed to be: a single pass
|
|
68
|
+
* can leave text the next pass would still change, and a `--write` run that
|
|
69
|
+
* takes one pass then reports success has called such a file formatted while
|
|
70
|
+
* `prettier --check` still rejects it (#125). Formatting to a fixpoint removes
|
|
71
|
+
* the assumption — the file lands on the value repeated formatting converges
|
|
72
|
+
* to, whatever it took to get there.
|
|
73
|
+
*
|
|
74
|
+
* Three, not "until it stops": a file that oscillates would loop forever, and
|
|
75
|
+
* the cap turns that into a report. Three is enough for the case this is for
|
|
76
|
+
* (one pass short) with a pass to spare, and costs nothing on a tree that is
|
|
77
|
+
* already formatted, where the first pass converges immediately.
|
|
78
|
+
*/
|
|
79
|
+
const MAX_FORMAT_PASSES = 3;
|
|
80
|
+
|
|
64
81
|
/**
|
|
65
82
|
* Every file under a root, minus the directories nothing should walk.
|
|
66
83
|
*
|
|
@@ -106,28 +123,27 @@ function walkFiles(root) {
|
|
|
106
123
|
* @param {readonly string[]} [opts.paths] - Files or directories to check
|
|
107
124
|
* instead of the whole root.
|
|
108
125
|
* @param {boolean} [opts.write=false] - Rewrite unformatted files in place
|
|
109
|
-
* rather than reporting them.
|
|
126
|
+
* rather than reporting them. Each file is formatted to a fixpoint (up to
|
|
127
|
+
* {@link MAX_FORMAT_PASSES} passes), so a written tree is one a second run
|
|
128
|
+
* leaves alone; a file that will not converge is reported and left unchanged
|
|
129
|
+
* (#125).
|
|
110
130
|
* @param {object} [opts.prettier] - The Prettier module, for tests.
|
|
111
131
|
* @returns {Promise<{findings: Array<{file: string, severity: string,
|
|
112
132
|
* message: string}>, checked: number, written: string[]}>} The findings, how
|
|
113
|
-
* many files were considered, and what was rewritten.
|
|
133
|
+
* many files were considered, and what was rewritten. `--write` reports
|
|
134
|
+
* findings too — a file it cannot parse, or cannot format to a fixpoint.
|
|
114
135
|
*/
|
|
115
136
|
export async function checkFormatting(root, opts = {}) {
|
|
116
137
|
const { paths, write = false } = opts;
|
|
117
138
|
const prettier = opts.prettier ?? (await import("prettier"));
|
|
118
139
|
const base = path.resolve(root);
|
|
119
|
-
const ignorePath = IGNORE_FILES.map((name) => path.join(base, name)).filter(
|
|
120
|
-
|
|
140
|
+
const ignorePath = IGNORE_FILES.map((name) => path.join(base, name)).filter((file) =>
|
|
141
|
+
fs.existsSync(file),
|
|
121
142
|
);
|
|
122
143
|
|
|
123
|
-
const roots =
|
|
124
|
-
paths?.length ?
|
|
125
|
-
paths.map((entry) => path.resolve(base, entry))
|
|
126
|
-
: [base];
|
|
144
|
+
const roots = paths?.length ? paths.map((entry) => path.resolve(base, entry)) : [base];
|
|
127
145
|
const candidates = roots.flatMap((entry) =>
|
|
128
|
-
fs.existsSync(entry) && fs.statSync(entry).isDirectory() ?
|
|
129
|
-
walkFiles(entry)
|
|
130
|
-
: [entry],
|
|
146
|
+
fs.existsSync(entry) && fs.statSync(entry).isDirectory() ? walkFiles(entry) : [entry],
|
|
131
147
|
);
|
|
132
148
|
|
|
133
149
|
const findings = [];
|
|
@@ -160,7 +176,38 @@ export async function checkFormatting(root, opts = {}) {
|
|
|
160
176
|
// every other one.
|
|
161
177
|
try {
|
|
162
178
|
if (write) {
|
|
163
|
-
|
|
179
|
+
// Format to a fixpoint rather than once, so what lands on disk
|
|
180
|
+
// is what a second run would have produced (#125).
|
|
181
|
+
let formatted = source;
|
|
182
|
+
let converged = false;
|
|
183
|
+
for (let pass = 0; pass < MAX_FORMAT_PASSES; pass += 1) {
|
|
184
|
+
const next = await prettier.format(formatted, options);
|
|
185
|
+
if (next === formatted) {
|
|
186
|
+
converged = true;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
formatted = next;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!converged) {
|
|
193
|
+
// Nothing is written. A formatting the command cannot
|
|
194
|
+
// reproduce is not one to commit to disk — writing it
|
|
195
|
+
// would make `--write` churn the file on every run — and a
|
|
196
|
+
// file that never settles is a defect somewhere that has
|
|
197
|
+
// to be named rather than absorbed.
|
|
198
|
+
findings.push({
|
|
199
|
+
file,
|
|
200
|
+
severity: "error",
|
|
201
|
+
// No line or column: the verdict is about the whole
|
|
202
|
+
// file, and #17's rule is to drop a field rather than
|
|
203
|
+
// invent one.
|
|
204
|
+
message:
|
|
205
|
+
`did not converge after ${MAX_FORMAT_PASSES} formatting passes; ` +
|
|
206
|
+
"left unchanged",
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
164
211
|
if (formatted !== source) {
|
|
165
212
|
fs.writeFileSync(file, formatted);
|
|
166
213
|
written.push(file);
|
|
@@ -175,8 +222,7 @@ export async function checkFormatting(root, opts = {}) {
|
|
|
175
222
|
// No line or column: Prettier's answer is about the whole
|
|
176
223
|
// file, and #17's rule is to drop a field rather than
|
|
177
224
|
// invent one.
|
|
178
|
-
message:
|
|
179
|
-
"is not formatted; run `content-build format --write` to fix it",
|
|
225
|
+
message: "is not formatted; run `content-build format --write` to fix it",
|
|
180
226
|
});
|
|
181
227
|
}
|
|
182
228
|
} catch (err) {
|
|
@@ -189,9 +235,7 @@ export async function checkFormatting(root, opts = {}) {
|
|
|
189
235
|
const loc = err?.loc?.start ?? err?.loc;
|
|
190
236
|
findings.push({
|
|
191
237
|
file,
|
|
192
|
-
...(Number.isFinite(loc?.line) ?
|
|
193
|
-
{ line: loc.line, column: loc.column }
|
|
194
|
-
: {}),
|
|
238
|
+
...(Number.isFinite(loc?.line) ? { line: loc.line, column: loc.column } : {}),
|
|
195
239
|
severity: "error",
|
|
196
240
|
message: `cannot be parsed: ${String(err?.message ?? err).split("\n")[0]}`,
|
|
197
241
|
});
|
package/engine/region-events.mjs
CHANGED
|
@@ -54,9 +54,7 @@ export const REGION_EVENT_TO_TRIGGER = Object.freeze({
|
|
|
54
54
|
*
|
|
55
55
|
* @type {readonly string[]}
|
|
56
56
|
*/
|
|
57
|
-
export const CURATED_REGION_EVENTS = Object.freeze(
|
|
58
|
-
Object.keys(REGION_EVENT_TO_TRIGGER),
|
|
59
|
-
);
|
|
57
|
+
export const CURATED_REGION_EVENTS = Object.freeze(Object.keys(REGION_EVENT_TO_TRIGGER));
|
|
60
58
|
|
|
61
59
|
/**
|
|
62
60
|
* Region events SoHL deliberately does **not** forward: the continuous
|
|
@@ -34,12 +34,24 @@
|
|
|
34
34
|
* *unresolvable*, silently — and it also suppressed real build failures, since
|
|
35
35
|
* a note the compilers never reached could not fail on the defects it carried.
|
|
36
36
|
*
|
|
37
|
+
* **A field retired in favour of another is a third case (#142).** `draft:` and
|
|
38
|
+
* `package:` were retired outright: nothing replaced them, so no value made
|
|
39
|
+
* writing one right and refusal was the only honest answer. A *renamed* field
|
|
40
|
+
* has a replacement, and the two spellings mean the same thing — so the note
|
|
41
|
+
* still compiles, correctly, and refusing it would fail a build over a document
|
|
42
|
+
* that is not wrong. Those retire in the three steps `package:` took (#56), and
|
|
43
|
+
* this module carries the **first**: both spellings are read, the current one
|
|
44
|
+
* wins, and the retired one is *reported* rather than refused. The sweep and
|
|
45
|
+
* the refusal come later, once no tree writes it. See
|
|
46
|
+
* {@link RETIRED_FIELD_ALIASES}.
|
|
47
|
+
*
|
|
37
48
|
* @module
|
|
38
49
|
*/
|
|
39
50
|
|
|
40
51
|
import fs from "node:fs";
|
|
41
52
|
|
|
42
53
|
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
54
|
+
import { sohlField } from "./frontmatter.mjs";
|
|
43
55
|
|
|
44
56
|
/**
|
|
45
57
|
* What a note declaring `draft:` is told, in one place.
|
|
@@ -105,12 +117,15 @@ export function assertNoDraftField(fm, { file, absPath } = {}) {
|
|
|
105
117
|
* both refusals need it and a second copy is a second thing to keep correct.
|
|
106
118
|
*
|
|
107
119
|
* @param {string|undefined} absPath - The note's file.
|
|
108
|
-
* @param {string} key - The
|
|
120
|
+
* @param {string} key - The frontmatter key.
|
|
121
|
+
* @param {string} [value] - When given, prefer the occurrence whose line also
|
|
122
|
+
* carries this text — so a finding about one entry of a block opens on that
|
|
123
|
+
* entry rather than on the key that introduces it.
|
|
109
124
|
* @returns {{line?: number, column?: number}|undefined} Spreadable position
|
|
110
125
|
* fields, dropped rather than guessed when the file cannot be read or the key
|
|
111
126
|
* cannot be found — as `formatDiagnostic` requires.
|
|
112
127
|
*/
|
|
113
|
-
export function locateFrontmatterKey(absPath, key) {
|
|
128
|
+
export function locateFrontmatterKey(absPath, key, value = undefined) {
|
|
114
129
|
if (!absPath) return undefined;
|
|
115
130
|
let raw;
|
|
116
131
|
try {
|
|
@@ -118,6 +133,105 @@ export function locateFrontmatterKey(absPath, key) {
|
|
|
118
133
|
} catch {
|
|
119
134
|
return undefined;
|
|
120
135
|
}
|
|
121
|
-
const at = positionInFrontmatter(raw, key);
|
|
136
|
+
const at = positionInFrontmatter(raw, key, value);
|
|
122
137
|
return at.line === undefined ? undefined : at;
|
|
123
138
|
}
|
|
139
|
+
|
|
140
|
+
/* -------------------------------------------------------------------- */
|
|
141
|
+
/* Retired *in favour of another field* */
|
|
142
|
+
/* -------------------------------------------------------------------- */
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The current field name a retired spelling was renamed to.
|
|
146
|
+
*
|
|
147
|
+
* Keyed by the **current** name, because that is what a type's schema declares
|
|
148
|
+
* and what every reader asks for; the value is the spelling still honoured.
|
|
149
|
+
* The table is therefore scoped by the schema without saying so twice: an alias
|
|
150
|
+
* applies to a note only where that note's type declares the current field, so
|
|
151
|
+
* `image` is retired on a map — which declares `img` — and remains an unknown
|
|
152
|
+
* key anywhere else.
|
|
153
|
+
*
|
|
154
|
+
* **`img` (#142).** Every note type names its artwork `img`, at the note's top
|
|
155
|
+
* level, and resolves it the same way. A map alone named its background art
|
|
156
|
+
* `image` and read it out of the `sohl:` block — two spellings for one idea,
|
|
157
|
+
* with nothing to reconcile them, and a specification that had to hedge rather
|
|
158
|
+
* than state a rule. Art is not system-specific: a Scene is a core Foundry
|
|
159
|
+
* document and HM3 would want the identical one, so the field belongs beside
|
|
160
|
+
* every other note's `img`, not inside a system block.
|
|
161
|
+
*
|
|
162
|
+
* @type {Readonly<Record<string, string>>}
|
|
163
|
+
*/
|
|
164
|
+
export const RETIRED_FIELD_ALIASES = Object.freeze({ img: "image" });
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What a note writing a renamed field is told, in one place.
|
|
168
|
+
*
|
|
169
|
+
* Shared by the compile-time report and the frontmatter lint, because an author
|
|
170
|
+
* meets whichever runs first and they should read the same. It names the key to
|
|
171
|
+
* write rather than a value to correct — no value makes the retired spelling
|
|
172
|
+
* right — and it says the note compiles either way, so a reader knows this is a
|
|
173
|
+
* rename to schedule rather than a build to unbreak.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} retired - The spelling the note used.
|
|
176
|
+
* @param {string} current - What to write instead.
|
|
177
|
+
* @param {string} [file] - The note's path, named in the message. Omit it where
|
|
178
|
+
* the caller emits through a diagnostic, whose locator already starts the
|
|
179
|
+
* line — repeating it prints the path twice.
|
|
180
|
+
* @returns {string} The message, unpunctuated at the end as a finding is.
|
|
181
|
+
*/
|
|
182
|
+
export function retiredAliasMessage(retired, current, file) {
|
|
183
|
+
return (
|
|
184
|
+
`\`${retired}:\` is a retired frontmatter field — write \`${current}:\` ` +
|
|
185
|
+
`instead` +
|
|
186
|
+
(file ? ` — ${file}` : "") +
|
|
187
|
+
`. Both are read and \`${current}\` wins, so the note compiles ` +
|
|
188
|
+
`identically either way; \`${retired}\` is removed in a later release`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether a note writes the retired spelling of a field, wherever it put it.
|
|
194
|
+
*
|
|
195
|
+
* Both regions are searched, because {@link sohlField} reads both: a note that
|
|
196
|
+
* moved the key to the top level without renaming it has done half the
|
|
197
|
+
* migration, and should be told so rather than passing in silence.
|
|
198
|
+
*
|
|
199
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
200
|
+
* @param {string} current - The field's current name.
|
|
201
|
+
* @returns {boolean} Whether the retired spelling is declared.
|
|
202
|
+
*/
|
|
203
|
+
export function declaresRetiredAlias(fm, current) {
|
|
204
|
+
const retired = RETIRED_FIELD_ALIASES[current];
|
|
205
|
+
if (!retired || !fm || typeof fm !== "object") return false;
|
|
206
|
+
const block = fm.sohl;
|
|
207
|
+
const inBlock =
|
|
208
|
+
block &&
|
|
209
|
+
typeof block === "object" &&
|
|
210
|
+
!Array.isArray(block) &&
|
|
211
|
+
Object.hasOwn(block, retired);
|
|
212
|
+
return Boolean(inBlock) || Object.hasOwn(fm, retired);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Read a field that has a retired spelling, the current name winning.
|
|
217
|
+
*
|
|
218
|
+
* This is the whole of the retirement window's behaviour, in one function, so
|
|
219
|
+
* the compiler and the linter cannot disagree about which value a note carries.
|
|
220
|
+
* Resolution within each spelling is {@link sohlField}'s — the `sohl:` block
|
|
221
|
+
* first, then the note's top level — so a renamed field keeps working wherever
|
|
222
|
+
* it was already written while the canonical home is the top level.
|
|
223
|
+
*
|
|
224
|
+
* A blank value counts as absent: `img:` cleared in an editor means the note
|
|
225
|
+
* names no art there, and falling through to the retired spelling is what an
|
|
226
|
+
* author part-way through the rename means by it.
|
|
227
|
+
*
|
|
228
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
229
|
+
* @param {string} current - The field's current name.
|
|
230
|
+
* @returns {any} The value, or `undefined` when neither spelling carries one.
|
|
231
|
+
*/
|
|
232
|
+
export function readAliasedField(fm, current) {
|
|
233
|
+
const value = sohlField(fm, current, undefined);
|
|
234
|
+
if (value !== undefined && value !== null && value !== "") return value;
|
|
235
|
+
const retired = RETIRED_FIELD_ALIASES[current];
|
|
236
|
+
return retired ? sohlField(fm, retired, undefined) : undefined;
|
|
237
|
+
}
|