@heroiclands/package-build 5.0.0 → 6.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 +319 -0
- package/CONTENT.md +136 -13
- package/MIGRATING.md +41 -0
- package/README.md +30 -0
- package/bin/content-build.mjs +20 -3
- package/bin/package-build.mjs +3 -1
- package/engine/base-compiler.mjs +28 -3
- package/engine/content-links.mjs +221 -2
- package/engine/content-lint.mjs +12 -3
- package/engine/diagnostics.mjs +46 -0
- package/engine/generate.mjs +156 -7
- package/engine/homepage.mjs +109 -0
- package/engine/pack-config.mjs +21 -0
- package/engine/site-build.mjs +10 -4
- package/manifest.mjs +195 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +14 -1
- package/sohl/item-fields.mjs +0 -5
- package/sohl/kb-passes.mjs +81 -14
- package/types/engine/base-compiler.d.mts +22 -0
- package/types/engine/content-links.d.mts +53 -2
- package/types/engine/diagnostics.d.mts +28 -0
- package/types/engine/generate.d.mts +50 -2
- package/types/engine/homepage.d.mts +49 -42
- package/types/engine/pack-config.d.mts +13 -0
- package/types/engine/site-build.d.mts +10 -4
- package/types/manifest.d.mts +67 -1
- package/types/sohl/kb-passes.d.mts +5 -1
package/bin/package-build.mjs
CHANGED
|
@@ -78,7 +78,7 @@ import yargs from "yargs";
|
|
|
78
78
|
import { hideBin } from "yargs/helpers";
|
|
79
79
|
|
|
80
80
|
import { loadPackageBuildConfig } from "../config.mjs";
|
|
81
|
-
import { loadPackConfig } from "../engine/pack-config.mjs";
|
|
81
|
+
import { loadPackConfig, packConfigPath } from "../engine/pack-config.mjs";
|
|
82
82
|
import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
|
|
83
83
|
import { validateLangSource } from "../lang.mjs";
|
|
84
84
|
import {
|
|
@@ -354,6 +354,8 @@ function manifestCommand() {
|
|
|
354
354
|
artifact: config.artifact,
|
|
355
355
|
outDir: path.join(config.rootDir, config.stageDir),
|
|
356
356
|
flags,
|
|
357
|
+
// So a `packFolders` finding names the line it is about.
|
|
358
|
+
configFile: packConfigPath(),
|
|
357
359
|
});
|
|
358
360
|
console.log(
|
|
359
361
|
`✅ Wrote ${path.relative(config.rootDir, written)} ` +
|
package/engine/base-compiler.mjs
CHANGED
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
* | {@link BasePackCompiler#finish} | Work that needs every note first. |
|
|
35
35
|
* | {@link BasePackCompiler#reportCompiled} / {@link BasePackCompiler#reportDetail} | The pass's own log lines. |
|
|
36
36
|
*
|
|
37
|
-
* plus
|
|
38
|
-
* merely skipped)
|
|
39
|
-
* `buildEntry` is converted or exactly as authored)
|
|
37
|
+
* plus three static switches — `requiresId` (a note with no id is fatal, or
|
|
38
|
+
* merely skipped), `convertsWikilinks` (whether the body reaching
|
|
39
|
+
* `buildEntry` is converted or exactly as authored) and `readsPackOutputOf`
|
|
40
|
+
* (the document types whose compiled output this pass reads, which is what the
|
|
41
|
+
* generator derives the compile order from).
|
|
40
42
|
*
|
|
41
43
|
* `selects` answers *which document type* a pass claims, and it is the same
|
|
42
44
|
* answer for every pack of that type. Which **pack of that type** a claimed
|
|
@@ -141,6 +143,29 @@ export class BasePackCompiler {
|
|
|
141
143
|
*/
|
|
142
144
|
static convertsWikilinks = true;
|
|
143
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The document types whose **compiled output** this pass reads.
|
|
148
|
+
*
|
|
149
|
+
* Empty for every pass that reads only the content tree. The actors pass
|
|
150
|
+
* is the exception: a being names its embedded items by
|
|
151
|
+
* `(type, shortcode)`, and it resolves them against the JSON the item
|
|
152
|
+
* passes wrote — so an Actor pass must run after every Item pass, and it
|
|
153
|
+
* says so here.
|
|
154
|
+
*
|
|
155
|
+
* The generator derives the compile order from this (#73), so the order
|
|
156
|
+
* `packs:` declares is presentation only — it is the manifest's `packs`
|
|
157
|
+
* array as well, and a consumer orders that for a reader. A pass that
|
|
158
|
+
* reads another's output states the dependency once, in the class that
|
|
159
|
+
* does the reading, instead of every consuming repository having to know
|
|
160
|
+
* it when writing its pack list.
|
|
161
|
+
*
|
|
162
|
+
* A consumer registering a compiler of its own declares its dependencies
|
|
163
|
+
* the same way; a type no pack declares is simply not waited for.
|
|
164
|
+
*
|
|
165
|
+
* @type {readonly string[]}
|
|
166
|
+
*/
|
|
167
|
+
static readsPackOutputOf = Object.freeze([]);
|
|
168
|
+
|
|
144
169
|
/** @type {string} */
|
|
145
170
|
contentBase;
|
|
146
171
|
/** @type {string} */
|
package/engine/content-links.mjs
CHANGED
|
@@ -61,6 +61,8 @@ import {
|
|
|
61
61
|
readCanonicalKey,
|
|
62
62
|
} from "./kb-manifest.mjs";
|
|
63
63
|
import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
|
|
64
|
+
import { homepageAddresses, isHomepage } from "./homepage.mjs";
|
|
65
|
+
import { RETIRED_TYPES } from "./ids.mjs";
|
|
64
66
|
import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
|
|
65
67
|
import { readQualifier } from "./wikilinks.mjs";
|
|
66
68
|
|
|
@@ -305,6 +307,12 @@ export function buildLinkIndex(
|
|
|
305
307
|
anchors,
|
|
306
308
|
types,
|
|
307
309
|
packages,
|
|
310
|
+
/**
|
|
311
|
+
* The one package this tree publishes. Distinct from `packages`, which
|
|
312
|
+
* is the set an address may name and which a homepage-only tree leaves
|
|
313
|
+
* this package out of, having no keyed note to put it there.
|
|
314
|
+
*/
|
|
315
|
+
contentPackage: pkg,
|
|
308
316
|
foreign,
|
|
309
317
|
manifests: manifestsComplete(localPackages, foreign.packages),
|
|
310
318
|
linksOf,
|
|
@@ -315,13 +323,223 @@ export function buildLinkIndex(
|
|
|
315
323
|
};
|
|
316
324
|
}
|
|
317
325
|
|
|
326
|
+
/**
|
|
327
|
+
* The site this project publishes on, as a host pattern.
|
|
328
|
+
*
|
|
329
|
+
* Hardcoded, as it is in {@link module:engine/homepage} already: every package's
|
|
330
|
+
* address is `https://www.heroiclands.org/<contentPackage>/`, and the whole
|
|
331
|
+
* point of the rule below is that an author *should not* be writing that host
|
|
332
|
+
* into a page. A configurable host would be a second place to write down the
|
|
333
|
+
* thing being discouraged.
|
|
334
|
+
*
|
|
335
|
+
* @type {RegExp}
|
|
336
|
+
*/
|
|
337
|
+
const SITE_HOST = /^(?:[a-z0-9-]+\.)*heroiclands\.org$/i;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* How an authored address resolves, or `null` for one nothing here can judge.
|
|
341
|
+
*
|
|
342
|
+
* Three shapes reach the site and one does not, and the distinction is the
|
|
343
|
+
* whole of what is checkable. An address into this site can be reasoned about
|
|
344
|
+
* from the package roster alone; an address to `github.com`, `kelestia.com` or
|
|
345
|
+
* `discord.gg` cannot be reasoned about at all without fetching it, and a build
|
|
346
|
+
* must not depend on a third party being up.
|
|
347
|
+
*
|
|
348
|
+
* @param {string} url - The authored address.
|
|
349
|
+
* @param {ReadonlySet<string>} packages - Package prefixes this build can name.
|
|
350
|
+
* @returns {{shape: string, segments: string[], prefix: string|null}|null} The
|
|
351
|
+
* shape, the path segments, and the package prefix the address starts with.
|
|
352
|
+
*/
|
|
353
|
+
function readAddress(url, packages) {
|
|
354
|
+
const value = String(url ?? "").trim();
|
|
355
|
+
if (!value || value.startsWith("#")) return null;
|
|
356
|
+
|
|
357
|
+
let segments;
|
|
358
|
+
let shape;
|
|
359
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
|
|
360
|
+
let parsed;
|
|
361
|
+
try {
|
|
362
|
+
parsed = new URL(value);
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
if (!/^https?:$/.test(parsed.protocol)) return null;
|
|
367
|
+
if (!SITE_HOST.test(parsed.hostname)) return null;
|
|
368
|
+
shape = "absolute";
|
|
369
|
+
segments = parsed.pathname.split("/").filter(Boolean);
|
|
370
|
+
} else if (value.startsWith("/")) {
|
|
371
|
+
shape = "rooted";
|
|
372
|
+
segments = value.split("?")[0].split("#")[0].split("/").filter(Boolean);
|
|
373
|
+
} else {
|
|
374
|
+
shape = "relative";
|
|
375
|
+
segments = value.split("?")[0].split("#")[0].split("/").filter(Boolean);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const prefix =
|
|
379
|
+
shape !== "relative" && packages.has(segments[0]) ? segments[0] : null;
|
|
380
|
+
return { shape, segments, prefix };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Every defect in the addresses a package homepage carries.
|
|
385
|
+
*
|
|
386
|
+
* **Why the homepage needs its own audit at all.** Every other note addresses
|
|
387
|
+
* the corpus with wikilinks, which {@link auditLinks} resolves. A homepage does
|
|
388
|
+
* not and cannot: it is published *verbatim* by every publishing mode, including
|
|
389
|
+
* the homepage-only mode two fan-licensed packages ship under, where the content
|
|
390
|
+
* tree is never walked and there is no index for a wikilink to resolve against.
|
|
391
|
+
* So a landing addresses the web the way the web does — markdown links and
|
|
392
|
+
* `url:` fields — and nothing was looking at those. SoHL's landing pointed at
|
|
393
|
+
* `kb/creature/` and `kb/character/` from the day those types merged into
|
|
394
|
+
* `being`: two 404s on the package's front page, through every build.
|
|
395
|
+
*
|
|
396
|
+
* **What is checkable, stated plainly.** Only an address into this site is, and
|
|
397
|
+
* only against facts this build already holds:
|
|
398
|
+
*
|
|
399
|
+
* - A **retired content type** in the path. The engine knows what used to exist
|
|
400
|
+
* and what replaced it, so this is a fact rather than a guess — and it is
|
|
401
|
+
* exactly the SoHL defect.
|
|
402
|
+
* - A **hardcoded absolute URL** into this package's own prefix, or into one a
|
|
403
|
+
* vendored manifest names. Both have a better form to write, which is why they
|
|
404
|
+
* are reported; a bare `/<package>/` is left alone, because a package
|
|
405
|
+
* homepage is in no manifest and there is nothing better to write.
|
|
406
|
+
* - A **root-relative `url:`**, which the theme's `relURL` prefixes a second
|
|
407
|
+
* time. `href:` means "already resolved, use verbatim", so the same leading
|
|
408
|
+
* slash is correct there and is not reported.
|
|
409
|
+
* - A **wikilink**, which nothing on this page will ever resolve.
|
|
410
|
+
*
|
|
411
|
+
* **What is not checkable, and is not attempted.** Whether an external URL
|
|
412
|
+
* answers — there is no network at build time, and a build must not fail because
|
|
413
|
+
* a third party is down. And whether a live in-site address names a page that
|
|
414
|
+
* exists: several of the surfaces a landing routes to are produced by other
|
|
415
|
+
* tools entirely (generated API documentation, hand-authored Hugo sections), so
|
|
416
|
+
* this build does not hold the set of published pages and would report a working
|
|
417
|
+
* link as dead.
|
|
418
|
+
*
|
|
419
|
+
* @param {ReturnType<typeof buildLinkIndex>} index - The built index.
|
|
420
|
+
* @returns {Array<{note: object, field: string, url: string, text: string,
|
|
421
|
+
* occurrence: number, message: string}>} One finding per defect, `text` and
|
|
422
|
+
* `occurrence` locating it in the note's raw source.
|
|
423
|
+
*/
|
|
424
|
+
export function auditHomepageLinks(index) {
|
|
425
|
+
const findings = [];
|
|
426
|
+
const packages = new Set([index.contentPackage, ...index.packages]);
|
|
427
|
+
|
|
428
|
+
for (const note of index.notes) {
|
|
429
|
+
if (!isHomepage(note.fm)) continue;
|
|
430
|
+
|
|
431
|
+
// How many times each literal has been seen, so two identical
|
|
432
|
+
// addresses are located at their own positions.
|
|
433
|
+
const seen = new Map();
|
|
434
|
+
const at = (text) => {
|
|
435
|
+
const occurrence = (seen.get(text) ?? 0) + 1;
|
|
436
|
+
seen.set(text, occurrence);
|
|
437
|
+
return occurrence;
|
|
438
|
+
};
|
|
439
|
+
const report = (field, url, text, occurrence, message) =>
|
|
440
|
+
findings.push({ note, field, url, text, occurrence, message });
|
|
441
|
+
|
|
442
|
+
for (const [all, rawInner] of matchAllOutsideCode(
|
|
443
|
+
note.body,
|
|
444
|
+
new RegExp(WIKILINK.source, "g"),
|
|
445
|
+
)) {
|
|
446
|
+
const { target } = parseWikilink(rawInner);
|
|
447
|
+
report(
|
|
448
|
+
"body",
|
|
449
|
+
target,
|
|
450
|
+
all,
|
|
451
|
+
at(all),
|
|
452
|
+
`wikilink ${all} on the package homepage — a homepage is ` +
|
|
453
|
+
`published verbatim in every publishing mode, so nothing ` +
|
|
454
|
+
`resolves it; write a markdown link, package-relative`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const { field, url, kind } of homepageAddresses(
|
|
459
|
+
note.fm,
|
|
460
|
+
note.body,
|
|
461
|
+
)) {
|
|
462
|
+
// Counted for every address, checked or not, so the count is
|
|
463
|
+
// the literal's nth appearance in the file rather than the nth
|
|
464
|
+
// *finding* about it — two rules can fire on one address.
|
|
465
|
+
const occurrence = at(url);
|
|
466
|
+
const address = readAddress(url, packages);
|
|
467
|
+
if (!address) continue;
|
|
468
|
+
const { shape, segments, prefix } = address;
|
|
469
|
+
|
|
470
|
+
if (shape === "absolute" && prefix) {
|
|
471
|
+
// A bare `/<package>/` is a package's homepage, which is in no
|
|
472
|
+
// link manifest and has no relative form from another package.
|
|
473
|
+
// A finding with no fix is noise.
|
|
474
|
+
const rest = segments.slice(1).join("/");
|
|
475
|
+
if (rest) {
|
|
476
|
+
report(
|
|
477
|
+
field,
|
|
478
|
+
url,
|
|
479
|
+
url,
|
|
480
|
+
occurrence,
|
|
481
|
+
prefix === index.contentPackage ?
|
|
482
|
+
`hardcoded absolute URL into this package's own ` +
|
|
483
|
+
`address — write the package-relative ` +
|
|
484
|
+
`"${rest}/", which the landing resolves ` +
|
|
485
|
+
`against the site so the page follows the mount`
|
|
486
|
+
: `hardcoded absolute URL into package "${prefix}" ` +
|
|
487
|
+
`— resolve it through that package's link ` +
|
|
488
|
+
`manifest, whose entries carry the address, so a ` +
|
|
489
|
+
`relocation does not leave this page behind`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
} else if (shape === "rooted" && kind === "url") {
|
|
493
|
+
const rest =
|
|
494
|
+
prefix ? segments.slice(1).join("/") : segments.join("/");
|
|
495
|
+
report(
|
|
496
|
+
field,
|
|
497
|
+
url,
|
|
498
|
+
url,
|
|
499
|
+
occurrence,
|
|
500
|
+
`url "${url}" is root-relative, but a landing's url: is ` +
|
|
501
|
+
`resolved against the site — write "${rest}/", or ` +
|
|
502
|
+
`href: for an address that is already resolved`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// The retired-type rule reads the path *inside* the package, so an
|
|
507
|
+
// address that named one is fixed the same way wherever it was
|
|
508
|
+
// written.
|
|
509
|
+
const inPackage = prefix ? segments.slice(1) : segments;
|
|
510
|
+
for (const [i, segment] of inPackage.entries()) {
|
|
511
|
+
// `hasOwn`, not a plain lookup: a path segment spelled
|
|
512
|
+
// `constructor` would otherwise inherit a truthy answer from
|
|
513
|
+
// `Object.prototype` and be reported as retired.
|
|
514
|
+
if (!Object.hasOwn(RETIRED_TYPES, segment)) continue;
|
|
515
|
+
const replacement = RETIRED_TYPES[segment];
|
|
516
|
+
const fixed = [...inPackage];
|
|
517
|
+
fixed[i] = replacement;
|
|
518
|
+
report(
|
|
519
|
+
field,
|
|
520
|
+
url,
|
|
521
|
+
url,
|
|
522
|
+
occurrence,
|
|
523
|
+
`address "${url}" names content type "${segment}", ` +
|
|
524
|
+
`retired in favour of "${replacement}" — both ` +
|
|
525
|
+
`compiled to the same document, so the fix is ` +
|
|
526
|
+
`mechanical: "${fixed.join("/")}/"`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return findings;
|
|
533
|
+
}
|
|
534
|
+
|
|
318
535
|
/**
|
|
319
536
|
* Every link in a tree that lands nowhere.
|
|
320
537
|
*
|
|
321
538
|
* @param {ReturnType<typeof buildLinkIndex>} index - The built index.
|
|
322
539
|
* @returns {{deadAnchors: object[], deadAddresses: object[],
|
|
323
|
-
* frontmatterLinks: object[],
|
|
324
|
-
* which addresses a foreign
|
|
540
|
+
* frontmatterLinks: object[], homepageLinks: object[],
|
|
541
|
+
* usedManifest: Set<string>}} The findings, and which addresses a foreign
|
|
542
|
+
* manifest answered.
|
|
325
543
|
*/
|
|
326
544
|
export function auditLinks(index) {
|
|
327
545
|
const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
|
|
@@ -370,6 +588,7 @@ export function auditLinks(index) {
|
|
|
370
588
|
deadAnchors,
|
|
371
589
|
deadAddresses,
|
|
372
590
|
frontmatterLinks: index.frontmatterLinks,
|
|
591
|
+
homepageLinks: auditHomepageLinks(index),
|
|
373
592
|
usedManifest,
|
|
374
593
|
};
|
|
375
594
|
}
|
package/engine/content-lint.mjs
CHANGED
|
@@ -164,15 +164,24 @@ export function lintContentTree(contentBase, { skipDirectories } = {}) {
|
|
|
164
164
|
// "Every one of nothing is unique" is a vacuous pass, and it is exactly
|
|
165
165
|
// what a tree that failed to check out produces — so the lint would go
|
|
166
166
|
// green on the one state it most needs to catch.
|
|
167
|
-
|
|
167
|
+
//
|
|
168
|
+
// The state that catches is an **empty walk**, not an empty key set (#77).
|
|
169
|
+
// A note may be keyless by design: a homepage carries no `shortcode`
|
|
170
|
+
// because it is addressed by the package rather than by a slug, so a
|
|
171
|
+
// package in `publish.site: homepage` mode has a content tree that is
|
|
172
|
+
// populated, correct, and permanently unkeyed. Reporting that as a missing
|
|
173
|
+
// checkout trains its author to stop reading the output — the one thing
|
|
174
|
+
// this guard needs them to do. A tree holding notes is therefore a tree;
|
|
175
|
+
// only a tree holding none is the absent one.
|
|
176
|
+
if (notes.length === 0) {
|
|
168
177
|
findings.push({
|
|
169
178
|
file: path.relative(process.cwd(), contentBase) || contentBase,
|
|
170
179
|
severity: "error",
|
|
171
180
|
message:
|
|
172
|
-
"holds no
|
|
181
|
+
"holds no content notes, so every rule here is vacuous — " +
|
|
173
182
|
"check that the content tree is present and that this is its root",
|
|
174
183
|
});
|
|
175
|
-
return { findings, notes:
|
|
184
|
+
return { findings, notes: 0, keys: 0 };
|
|
176
185
|
}
|
|
177
186
|
|
|
178
187
|
for (const [key, files] of byKey) {
|
package/engine/diagnostics.mjs
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import path from "node:path";
|
|
47
|
+
import YAML, { LineCounter } from "yaml";
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* The `file:line:column` locator, with whatever is known.
|
|
@@ -268,3 +269,48 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
|
|
|
268
269
|
column: at - before.lastIndexOf("\n"),
|
|
269
270
|
};
|
|
270
271
|
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Where a **key or value in a YAML document** sits, addressed by its path.
|
|
275
|
+
*
|
|
276
|
+
* {@link positionOfLiteral} is the plain search, and it is the wrong tool for a
|
|
277
|
+
* configuration file: a pack name like `items` or `actors` appears in the
|
|
278
|
+
* `packs:` block, in a folder's list, in a path, and often in prose, so the
|
|
279
|
+
* first occurrence is routinely not the one the finding is about — which is the
|
|
280
|
+
* one thing the located form exists to prevent.
|
|
281
|
+
*
|
|
282
|
+
* A path resolves the exact node instead. The document is re-parsed here rather
|
|
283
|
+
* than threaded from the loader because the loader returns plain data: `yaml`
|
|
284
|
+
* discards ranges once a document is materialised, and carrying a parallel
|
|
285
|
+
* position tree through configuration resolution would be a second
|
|
286
|
+
* representation of the same file to keep in step.
|
|
287
|
+
*
|
|
288
|
+
* Every failure — unparseable text, an `.mjs` configuration, a path that
|
|
289
|
+
* resolves to nothing — yields `{}`, so a caller spreads the result and the
|
|
290
|
+
* position is dropped rather than guessed.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} text - The document's contents.
|
|
293
|
+
* @param {ReadonlyArray<string|number>} keyPath - Path to the node: map keys as
|
|
294
|
+
* strings, sequence entries as numbers.
|
|
295
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields.
|
|
296
|
+
*/
|
|
297
|
+
export function positionOfYamlPath(text, keyPath) {
|
|
298
|
+
if (typeof text !== "string" || !text) return {};
|
|
299
|
+
if (!Array.isArray(keyPath) || keyPath.length === 0) return {};
|
|
300
|
+
|
|
301
|
+
let node;
|
|
302
|
+
const counter = new LineCounter();
|
|
303
|
+
try {
|
|
304
|
+
const doc = YAML.parseDocument(text, { lineCounter: counter });
|
|
305
|
+
// `keepScalar` returns the Scalar node rather than its value, which is
|
|
306
|
+
// the only form carrying a range.
|
|
307
|
+
node = doc.getIn(keyPath, true);
|
|
308
|
+
} catch {
|
|
309
|
+
return {};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const start = node?.range?.[0];
|
|
313
|
+
if (!Number.isFinite(start)) return {};
|
|
314
|
+
const { line, col } = counter.linePos(start);
|
|
315
|
+
return { line, column: col };
|
|
316
|
+
}
|
package/engine/generate.mjs
CHANGED
|
@@ -23,10 +23,15 @@
|
|
|
23
23
|
* Each `*` compiler walks the whole content tree and selects its own entries by
|
|
24
24
|
* the note's `type` — every note in the tree belongs to this repository's
|
|
25
25
|
* `contentPackage` (#56) — so routing is directory-agnostic: a file lands in a
|
|
26
|
-
* pack because of its `type`, not its location. Which packs exist
|
|
27
|
-
* hierarchy each one loads are
|
|
28
|
-
* folder files live under the content root and are
|
|
29
|
-
* frontmatter via `sohl.folder: <id>`.
|
|
26
|
+
* pack because of its `type`, not its location. Which packs exist and which
|
|
27
|
+
* folder hierarchy each one loads are declared in
|
|
28
|
+
* `package-build.config.yaml`; folder files live under the content root and are
|
|
29
|
+
* referenced from entry frontmatter via `sohl.folder: <id>`.
|
|
30
|
+
*
|
|
31
|
+
* **The order the passes run in is derived, not declared** — see
|
|
32
|
+
* {@link orderPassesByDependency}. The declared list is the manifest's `packs`
|
|
33
|
+
* array as well, so it is ordered for a reader; a pass that reads another's
|
|
34
|
+
* output states that on its compiler and is scheduled after it (#73).
|
|
30
35
|
*
|
|
31
36
|
* This replaces the retired `packs:export` (vault → committed `_source/`); the
|
|
32
37
|
* HeroicLands vault is no longer a build input for SoHL content.
|
|
@@ -50,6 +55,7 @@ import {
|
|
|
50
55
|
writeFolderDocs,
|
|
51
56
|
} from "./helpers.mjs";
|
|
52
57
|
import { countContentNotes } from "./content-tree.mjs";
|
|
58
|
+
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
53
59
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
54
60
|
import { routerFor } from "./pack-router.mjs";
|
|
55
61
|
|
|
@@ -89,8 +95,9 @@ export const packJsonDir = (name, config = loadPackConfig()) =>
|
|
|
89
95
|
* and an actor's embedded items may be sourced from any of them. Finding one
|
|
90
96
|
* pack and stopping is how embedded-item resolution would silently miss every
|
|
91
97
|
* item that landed in another. Returned in configured order, which is also the
|
|
92
|
-
* order they compile in
|
|
93
|
-
* is written
|
|
98
|
+
* order they compile in — {@link orderPassesByDependency} keeps the declared
|
|
99
|
+
* order among packs of one type — and every one of them is written before the
|
|
100
|
+
* actors pass that reads them.
|
|
94
101
|
*
|
|
95
102
|
* @param {object} [config] - The resolved build configuration. Defaults to this
|
|
96
103
|
* repository's.
|
|
@@ -104,6 +111,126 @@ export function itemPackJsonDirs(config = loadPackConfig()) {
|
|
|
104
111
|
.map((pack) => packJsonDir(pack.name, config));
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The document types whose compiled output a pass of this type reads.
|
|
116
|
+
*
|
|
117
|
+
* Asked of the compiler rather than held in a table here, so the dependency
|
|
118
|
+
* lives in the class that does the reading and a consumer's own compiler can
|
|
119
|
+
* declare its own. A type no compiler is registered for waits on nothing —
|
|
120
|
+
* {@link generatePack} reports it as an unknown type, which is the better
|
|
121
|
+
* message.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} type - A pack's document type.
|
|
124
|
+
* @returns {readonly string[]} The types it reads the output of.
|
|
125
|
+
*/
|
|
126
|
+
function readsOutputOf(type) {
|
|
127
|
+
return COMPILERS[type]?.readsPackOutputOf ?? [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The passes to run, ordered so that each one follows the output it reads.
|
|
132
|
+
*
|
|
133
|
+
* **Declaration order is presentation, not compile order (#73).** The same
|
|
134
|
+
* `packs:` list is the manifest's `packs` array, which a consumer orders for a
|
|
135
|
+
* reader browsing compendiums; the actors pass, meanwhile, resolves each
|
|
136
|
+
* being's embedded items against the item passes' *output*. Making one list
|
|
137
|
+
* satisfy both meant an Actor pack declared first compiled only where a
|
|
138
|
+
* previous run had already left `build/packs-json` populated — green on a warm
|
|
139
|
+
* tree, exit 1 on every fresh checkout and every CI runner, and `build/` is
|
|
140
|
+
* gitignored so that is the state CI always starts from.
|
|
141
|
+
*
|
|
142
|
+
* **The reordering is the smallest one that works.** Each step takes the
|
|
143
|
+
* *earliest declared* pass whose dependencies are all already emitted, so a
|
|
144
|
+
* list that was already in a workable order comes back untouched, and one that
|
|
145
|
+
* was not moves exactly the passes that had to move. A dependency is satisfied
|
|
146
|
+
* only when **every** pack of that type has run: a being addresses an item by
|
|
147
|
+
* `(type, shortcode)` without knowing which Item pack ships it, so waiting for
|
|
148
|
+
* one of several would resolve some beings and silently fail others.
|
|
149
|
+
*
|
|
150
|
+
* A dependency on a type this configuration declares no pack of is not waited
|
|
151
|
+
* for. A package may ship an Actor pack and no Item pack; the pass that needs
|
|
152
|
+
* one refuses on its own, with a message about items rather than about order.
|
|
153
|
+
*
|
|
154
|
+
* @param {readonly object[]} packs - The passes to be run, as declared.
|
|
155
|
+
* @returns {object[]} A new list, in compile order. The input is untouched.
|
|
156
|
+
* @throws {Error} If the passes read each other's output in a cycle, which no
|
|
157
|
+
* order can satisfy. Only reachable from a mis-declared compiler, so it names
|
|
158
|
+
* the passes rather than blaming the pack list.
|
|
159
|
+
*/
|
|
160
|
+
export function orderPassesByDependency(packs) {
|
|
161
|
+
const declared = new Set(packs.map((pack) => pack.type));
|
|
162
|
+
const remaining = [...packs];
|
|
163
|
+
const emitted = new Set();
|
|
164
|
+
/** @type {object[]} */
|
|
165
|
+
const ordered = [];
|
|
166
|
+
|
|
167
|
+
/** Whether every pack this pass reads the output of has already run. */
|
|
168
|
+
const ready = (pack) =>
|
|
169
|
+
readsOutputOf(pack.type).every(
|
|
170
|
+
(dependency) =>
|
|
171
|
+
!declared.has(dependency) ||
|
|
172
|
+
packs.every(
|
|
173
|
+
(other) =>
|
|
174
|
+
other.type !== dependency || emitted.has(other.name),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
while (remaining.length) {
|
|
179
|
+
const next = remaining.findIndex(ready);
|
|
180
|
+
if (next === -1) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`package-build: the configured passes read each other's ` +
|
|
183
|
+
`output in a cycle (` +
|
|
184
|
+
`${remaining.map((pack) => `${pack.name} (${pack.type})`).join(", ")}` +
|
|
185
|
+
`); no compile order can satisfy that.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const [pack] = remaining.splice(next, 1);
|
|
189
|
+
emitted.add(pack.name);
|
|
190
|
+
ordered.push(pack);
|
|
191
|
+
}
|
|
192
|
+
return ordered;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The dependencies this run cannot satisfy by ordering, because the pass that
|
|
197
|
+
* would produce them is not in it.
|
|
198
|
+
*
|
|
199
|
+
* Ordering answers the whole-package build; a run restricted to one pack
|
|
200
|
+
* (`content-build package compile <name>`) cannot conjure the passes it left
|
|
201
|
+
* out. Where their output is already on disk from an earlier run that is fine
|
|
202
|
+
* — it is how compiling one pack at a time is meant to work — so this reports
|
|
203
|
+
* only what is genuinely absent, and names the pack that would write it rather
|
|
204
|
+
* than the directory that is missing.
|
|
205
|
+
*
|
|
206
|
+
* @param {readonly object[]} running - The passes this run will execute.
|
|
207
|
+
* @param {object} config - The resolved build configuration.
|
|
208
|
+
* @returns {string[]} One message per unsatisfiable dependency.
|
|
209
|
+
*/
|
|
210
|
+
export function unsatisfiedPassDependencies(running, config) {
|
|
211
|
+
const included = new Set(running.map((pack) => pack.name));
|
|
212
|
+
/** @type {string[]} */
|
|
213
|
+
const messages = [];
|
|
214
|
+
for (const pack of running) {
|
|
215
|
+
for (const dependency of readsOutputOf(pack.type)) {
|
|
216
|
+
for (const producer of config.packs) {
|
|
217
|
+
if (producer.type !== dependency) continue;
|
|
218
|
+
if (included.has(producer.name)) continue;
|
|
219
|
+
const dir = packJsonDir(producer.name, config);
|
|
220
|
+
if (fs.existsSync(dir)) continue;
|
|
221
|
+
messages.push(
|
|
222
|
+
`pack "${pack.name}" (${pack.type}) reads the compiled ` +
|
|
223
|
+
`output of the ${producer.type} pack ` +
|
|
224
|
+
`"${producer.name}", which this run does not compile ` +
|
|
225
|
+
`and which ${dir} does not hold — compile the whole ` +
|
|
226
|
+
`package, or compile "${producer.name}" first`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return messages;
|
|
232
|
+
}
|
|
233
|
+
|
|
107
234
|
/**
|
|
108
235
|
* Generate the per-entry JSON for one pack into `build/packs-json/<name>/`.
|
|
109
236
|
*
|
|
@@ -280,9 +407,31 @@ export async function generatePacksJson({
|
|
|
280
407
|
if (!firstOfType.has(pack.type)) firstOfType.set(pack.type, pack.name);
|
|
281
408
|
}
|
|
282
409
|
|
|
410
|
+
// Compile order is derived from what each pass reads, not from the order
|
|
411
|
+
// `packs:` declares — that list is also the manifest's, which a consumer
|
|
412
|
+
// orders for a reader (#73).
|
|
413
|
+
const ordered = orderPassesByDependency(packs);
|
|
414
|
+
if (ordered.some((pack, index) => pack !== packs[index])) {
|
|
415
|
+
log.info(
|
|
416
|
+
`Pass order: ${ordered.map((pack) => pack.name).join(", ")} — a ` +
|
|
417
|
+
`pass that reads another's output compiles after it, whatever ` +
|
|
418
|
+
`order \`packs:\` declares.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// What ordering cannot reach: a run restricted to one pack, whose
|
|
423
|
+
// dependencies are simply not in it and not on disk either.
|
|
424
|
+
const unsatisfied = unsatisfiedPassDependencies(ordered, config);
|
|
425
|
+
if (unsatisfied.length) {
|
|
426
|
+
for (const message of unsatisfied) {
|
|
427
|
+
emitDiagnostic({ severity: "error", message });
|
|
428
|
+
}
|
|
429
|
+
return unsatisfied.length;
|
|
430
|
+
}
|
|
431
|
+
|
|
283
432
|
let totalErrors = 0;
|
|
284
433
|
const passes = [];
|
|
285
|
-
for (const pack of
|
|
434
|
+
for (const pack of ordered) {
|
|
286
435
|
const { errors, compiled } = await generatePack(
|
|
287
436
|
pack,
|
|
288
437
|
config,
|