@heroiclands/package-build 6.1.0 → 8.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 +840 -0
- package/CONTENT.md +21 -1
- package/bin/content-build.mjs +105 -10
- package/bin/package-build.mjs +114 -1
- package/config.mjs +62 -3
- package/content-config.mjs +254 -22
- package/engine/base-compiler.mjs +25 -0
- package/engine/content-links.mjs +132 -27
- package/engine/diagnostics.mjs +61 -1
- package/engine/foreign-catalog.mjs +47 -0
- package/engine/generate.mjs +10 -5
- package/engine/helpers.mjs +38 -0
- package/engine/journals.mjs +8 -1
- package/engine/macros.mjs +2 -0
- package/engine/pack-config.mjs +143 -13
- package/engine/prose-lint.mjs +10 -2
- package/engine/scenes.mjs +2 -2
- package/engine/schema-check.mjs +332 -0
- package/engine/schema-extract.mjs +611 -0
- package/engine/web-wikilinks.mjs +13 -4
- package/engine/wikilink-syntax.mjs +25 -0
- package/engine/wikilinks.mjs +6 -3
- package/manifest.mjs +37 -2
- package/package.json +5 -3
- package/sohl/actors.mjs +23 -10
- package/sohl/item-fields.mjs +0 -35
- package/sohl/items.mjs +1 -1
- package/types/content-config.d.mts +14 -0
- package/types/engine/base-compiler.d.mts +18 -1
- package/types/engine/content-links.d.mts +11 -3
- package/types/engine/diagnostics.d.mts +33 -1
- package/types/engine/foreign-catalog.d.mts +15 -0
- package/types/engine/generate.d.mts +3 -2
- package/types/engine/helpers.d.mts +29 -3
- package/types/engine/journals.d.mts +7 -1
- package/types/engine/pack-config.d.mts +22 -0
- package/types/engine/prose-lint.d.mts +10 -2
- package/types/engine/schema-check.d.mts +176 -0
- package/types/engine/schema-extract.d.mts +61 -0
- package/types/engine/wikilink-syntax.d.mts +24 -0
- package/types/sohl/actors.d.mts +3 -3
package/content-config.mjs
CHANGED
|
@@ -584,9 +584,12 @@ const CONFIG_KEYS = [
|
|
|
584
584
|
"site",
|
|
585
585
|
"compatibility",
|
|
586
586
|
"relationships",
|
|
587
|
+
"systems",
|
|
588
|
+
"requiresSystem",
|
|
587
589
|
"packageBuild",
|
|
588
590
|
"publish",
|
|
589
591
|
];
|
|
592
|
+
const SYSTEM_KEYS = ["manifest", "compatibility"];
|
|
590
593
|
const COMPATIBILITY_KEYS = ["minimum", "verified"];
|
|
591
594
|
const DOCS_KEYS = ["itemFields"];
|
|
592
595
|
const SITE_KEYS = [
|
|
@@ -626,7 +629,24 @@ const PACK_KEYS = [
|
|
|
626
629
|
"system",
|
|
627
630
|
];
|
|
628
631
|
const PATH_KEYS = Object.keys(DEFAULT_PATHS);
|
|
629
|
-
const STATS_KEYS = ["
|
|
632
|
+
const STATS_KEYS = ["lastModifiedBy"];
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* How the loader hands {@link defineConfig} the system version it resolved.
|
|
636
|
+
*
|
|
637
|
+
* A **Symbol**, deliberately. `stats.systemVersion` is refused from an authored
|
|
638
|
+
* configuration (#48), but the value still has to reach here from the loader —
|
|
639
|
+
* which is the half that may do I/O, and which reads a system package's version
|
|
640
|
+
* out of the adjacent `package.json`. A string key would be a second spelling of
|
|
641
|
+
* the refused one, forgeable from YAML and reachable by `rejectUnknownKeys`; a
|
|
642
|
+
* symbol key cannot be written in YAML at all and does not appear in
|
|
643
|
+
* `Object.keys`, so the refusal has no back door.
|
|
644
|
+
*
|
|
645
|
+
* @type {symbol}
|
|
646
|
+
*/
|
|
647
|
+
export const DERIVED_SYSTEM_VERSION = Symbol.for(
|
|
648
|
+
"package-build.derivedSystemVersion",
|
|
649
|
+
);
|
|
630
650
|
const PUBLISH_KEYS = ["site", "manifests", "address"];
|
|
631
651
|
const MANIFEST_KEYS = ["publish", "consume"];
|
|
632
652
|
const ADDRESS_KEYS = ["prefix", "landing"];
|
|
@@ -637,12 +657,25 @@ function isPlainObject(value) {
|
|
|
637
657
|
}
|
|
638
658
|
|
|
639
659
|
/**
|
|
640
|
-
*
|
|
641
|
-
*
|
|
660
|
+
* Reject a configured value, naming the key it was written under.
|
|
661
|
+
*
|
|
662
|
+
* The dotted path is carried on the error as `field` as well as spelled into
|
|
663
|
+
* the message, because the message alone is a good description and a bad
|
|
664
|
+
* locator: the loader that read the file can resolve that path to a line and
|
|
665
|
+
* column, and does (`locateConfigError` in `engine/pack-config.mjs`, #95).
|
|
666
|
+
* Attaching it here rather than formatting here is what keeps this module
|
|
667
|
+
* free of I/O — it is the leaf an `.mjs` configuration imports, so it may not
|
|
668
|
+
* reach for the file it is validating.
|
|
669
|
+
*
|
|
670
|
+
* @param {string} field - Dotted path of the offending key.
|
|
671
|
+
* @param {string} problem - What is wrong with it.
|
|
642
672
|
* @returns {never}
|
|
643
673
|
*/
|
|
644
674
|
function fail(field, problem) {
|
|
645
|
-
throw
|
|
675
|
+
throw Object.assign(
|
|
676
|
+
new TypeError(`package-build config: \`${field}\` ${problem}.`),
|
|
677
|
+
{ field },
|
|
678
|
+
);
|
|
646
679
|
}
|
|
647
680
|
|
|
648
681
|
/**
|
|
@@ -869,28 +902,42 @@ function normalizePaths(value, rootDir) {
|
|
|
869
902
|
* @param {unknown} value
|
|
870
903
|
* @returns {Readonly<StatsSpec>}
|
|
871
904
|
*/
|
|
872
|
-
function normalizeStats(value) {
|
|
905
|
+
function normalizeStats(value, derived) {
|
|
873
906
|
if (!isPlainObject(value)) fail("stats", "must be an object");
|
|
874
907
|
const input = /** @type {Record<string, unknown>} */ (value);
|
|
908
|
+
|
|
909
|
+
// **`systemId` and `systemVersion` are derived, and authoring a derived
|
|
910
|
+
// value is an error rather than an override (#48).** `systems:` is the
|
|
911
|
+
// single source: it says which systems this package stamps against, and
|
|
912
|
+
// `requiresSystem` — or a lone declared system — says which one the
|
|
913
|
+
// package-wide block takes. A system package answers for itself.
|
|
914
|
+
//
|
|
915
|
+
// Refused rather than ignored, because the two would silently disagree.
|
|
916
|
+
// That is exactly how `stats.systemVersion` came to sit at `0.6.0` for four
|
|
917
|
+
// releases: a transcribed copy is free to drift from what it copied, and
|
|
918
|
+
// nothing reads a stamped `_stats` until something migrates on it.
|
|
919
|
+
for (const key of ["systemId", "systemVersion"]) {
|
|
920
|
+
if (input[key] === undefined) continue;
|
|
921
|
+
fail(
|
|
922
|
+
`stats.${key}`,
|
|
923
|
+
`is derived and may not be authored. ` +
|
|
924
|
+
(key === "systemId" ?
|
|
925
|
+
`A system package is its own system; a module takes it ` +
|
|
926
|
+
`from \`requiresSystem\`, or from \`systems:\` when it ` +
|
|
927
|
+
`declares exactly one. `
|
|
928
|
+
: `It is the \`compatibility.verified\` of the system in ` +
|
|
929
|
+
`\`systems:\`, or a system package's own \`package.json\` ` +
|
|
930
|
+
`version. `) +
|
|
931
|
+
`Remove the key`,
|
|
932
|
+
);
|
|
933
|
+
}
|
|
875
934
|
rejectUnknownKeys(input, STATS_KEYS, "stats.");
|
|
876
935
|
|
|
877
936
|
return Object.freeze({
|
|
878
|
-
//
|
|
879
|
-
// the
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
input.systemId === undefined || input.systemId === null ?
|
|
883
|
-
null
|
|
884
|
-
: requireNonEmptyString(input.systemId, "stats.systemId"),
|
|
885
|
-
// Optional for the same reason as `systemId`: a system-agnostic module
|
|
886
|
-
// is not built against a system, so it has no version of one to stamp.
|
|
887
|
-
systemVersion:
|
|
888
|
-
input.systemVersion === undefined || input.systemVersion === null ?
|
|
889
|
-
null
|
|
890
|
-
: requireNonEmptyString(
|
|
891
|
-
input.systemVersion,
|
|
892
|
-
"stats.systemVersion",
|
|
893
|
-
),
|
|
937
|
+
// Per pack where the packs differ — see `statsForPack` — and this is
|
|
938
|
+
// the package-wide answer for everything that has no pack in hand.
|
|
939
|
+
systemId: derived.systemId,
|
|
940
|
+
systemVersion: derived.systemVersion,
|
|
894
941
|
lastModifiedBy: requireNonEmptyString(
|
|
895
942
|
input.lastModifiedBy,
|
|
896
943
|
"stats.lastModifiedBy",
|
|
@@ -1184,6 +1231,98 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
|
|
|
1184
1231
|
* @param {unknown} value - The `relationships` block, or `undefined`.
|
|
1185
1232
|
* @returns {Readonly<Relationships>} It, frozen; `{}` when absent.
|
|
1186
1233
|
*/
|
|
1234
|
+
/**
|
|
1235
|
+
* The systems this package can stamp content against — declaration only (#48).
|
|
1236
|
+
*
|
|
1237
|
+
* **Declaring is not requiring, and that separation is the whole point.** The
|
|
1238
|
+
* only place to state a system version used to be `relationships.systems`, and
|
|
1239
|
+
* that list is a *restriction*: Foundry's `supportsSystem` drops a module from
|
|
1240
|
+
* any world whose system it does not name. So a module shipping content for two
|
|
1241
|
+
* systems — `harn-ensemble` ships an HM3 pack, a SoHL pack and a system-neutral
|
|
1242
|
+
* journals pack — had to choose between naming its systems and remaining
|
|
1243
|
+
* loadable, and choosing the second meant stamping no system version at all on
|
|
1244
|
+
* content that certainly has one.
|
|
1245
|
+
*
|
|
1246
|
+
* Naming a system here restricts nothing. {@link normalizeRequiresSystem} is
|
|
1247
|
+
* what restricts, and it is separate and optional.
|
|
1248
|
+
*
|
|
1249
|
+
* Each entry carries the same `compatibility` shape a relationship does, and
|
|
1250
|
+
* `verified` is what a pack stamps: `_stats.systemVersion` records what the
|
|
1251
|
+
* content was *built against*, not the floor it tolerates.
|
|
1252
|
+
*
|
|
1253
|
+
* @param {unknown} value - The declared `systems:` mapping.
|
|
1254
|
+
* @returns {Readonly<Record<string, Readonly<object>>>} Frozen; `{}` when absent.
|
|
1255
|
+
*/
|
|
1256
|
+
function normalizeSystems(value) {
|
|
1257
|
+
if (value === undefined || value === null) return Object.freeze({});
|
|
1258
|
+
if (!isPlainObject(value))
|
|
1259
|
+
fail("systems", "must be a mapping of id to spec");
|
|
1260
|
+
const input = /** @type {Record<string, unknown>} */ (value);
|
|
1261
|
+
|
|
1262
|
+
const out = {};
|
|
1263
|
+
for (const [id, entry] of Object.entries(input)) {
|
|
1264
|
+
const at = `systems.${id}`;
|
|
1265
|
+
if (!id) fail("systems", "declares an empty system id");
|
|
1266
|
+
if (!isPlainObject(entry)) fail(at, "must be a mapping");
|
|
1267
|
+
const spec = /** @type {Record<string, unknown>} */ (entry);
|
|
1268
|
+
rejectUnknownKeys(spec, SYSTEM_KEYS, `${at}.`);
|
|
1269
|
+
|
|
1270
|
+
const compatibility = spec.compatibility;
|
|
1271
|
+
if (!isPlainObject(compatibility)) {
|
|
1272
|
+
fail(`${at}.compatibility`, "must be a mapping");
|
|
1273
|
+
}
|
|
1274
|
+
const compat = /** @type {Record<string, unknown>} */ (compatibility);
|
|
1275
|
+
rejectUnknownKeys(compat, COMPATIBILITY_KEYS, `${at}.compatibility.`);
|
|
1276
|
+
// `verified` is required because it is the value a pack stamps. A
|
|
1277
|
+
// declaration that cannot answer "which version was this built
|
|
1278
|
+
// against" is the gap this block exists to close.
|
|
1279
|
+
const verified = requireNonEmptyString(
|
|
1280
|
+
compat.verified,
|
|
1281
|
+
`${at}.compatibility.verified`,
|
|
1282
|
+
);
|
|
1283
|
+
|
|
1284
|
+
out[id] = Object.freeze({
|
|
1285
|
+
manifest:
|
|
1286
|
+
spec.manifest === undefined || spec.manifest === null ?
|
|
1287
|
+
null
|
|
1288
|
+
: requireNonEmptyString(spec.manifest, `${at}.manifest`),
|
|
1289
|
+
compatibility: Object.freeze({
|
|
1290
|
+
minimum:
|
|
1291
|
+
compat.minimum === undefined || compat.minimum === null ?
|
|
1292
|
+
null
|
|
1293
|
+
: requireNonEmptyString(
|
|
1294
|
+
compat.minimum,
|
|
1295
|
+
`${at}.compatibility.minimum`,
|
|
1296
|
+
),
|
|
1297
|
+
verified,
|
|
1298
|
+
}),
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
return Object.freeze(out);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* The one system this package refuses to load without, or `null` (#48).
|
|
1306
|
+
*
|
|
1307
|
+
* The gate half of the split. Naming a system here emits
|
|
1308
|
+
* `relationships.systems` for it, which is what Foundry's `supportsSystem`
|
|
1309
|
+
* reads — so the package becomes unavailable under any other system. Omitted,
|
|
1310
|
+
* no relationship is emitted and the package loads anywhere, each pack stamping
|
|
1311
|
+
* whatever its own `system:` names.
|
|
1312
|
+
*
|
|
1313
|
+
* It reuses the {@link normalizeSystems} entry rather than restating the
|
|
1314
|
+
* compatibility: `stats.systemVersion` froze at `0.6.0` for four releases
|
|
1315
|
+
* because a transcription was free to disagree with what it copied, and a
|
|
1316
|
+
* second transcription invites the same.
|
|
1317
|
+
*
|
|
1318
|
+
* @param {unknown} value - The declared `requiresSystem:`.
|
|
1319
|
+
* @returns {string|null} The system id, or `null`.
|
|
1320
|
+
*/
|
|
1321
|
+
function normalizeRequiresSystem(value) {
|
|
1322
|
+
if (value === undefined || value === null) return null;
|
|
1323
|
+
return requireNonEmptyString(value, "requiresSystem");
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1187
1326
|
function normalizeRelationships(value) {
|
|
1188
1327
|
if (value === undefined) return Object.freeze({});
|
|
1189
1328
|
if (!isPlainObject(value)) fail("relationships", "must be a mapping");
|
|
@@ -1541,6 +1680,54 @@ export function defineConfig(config) {
|
|
|
1541
1680
|
seen.add(name);
|
|
1542
1681
|
}
|
|
1543
1682
|
|
|
1683
|
+
// ── systems: declaring, and requiring, are separate decisions (#48) ──────
|
|
1684
|
+
const systems = normalizeSystems(input.systems);
|
|
1685
|
+
const requiresSystem = normalizeRequiresSystem(input.requiresSystem);
|
|
1686
|
+
const declaredSystems = new Set(Object.keys(systems));
|
|
1687
|
+
/** `relationships.systems`, for the derivations that still consult it. */
|
|
1688
|
+
const relationshipSystems = /** @type {{id?: string}[]} */ (
|
|
1689
|
+
(isPlainObject(input.relationships) ?
|
|
1690
|
+
input.relationships.systems
|
|
1691
|
+
: null) ?? []
|
|
1692
|
+
);
|
|
1693
|
+
|
|
1694
|
+
// A name that resolves to nothing is a build error rather than a
|
|
1695
|
+
// fall-through, in the spirit the rest of this file already follows: a pack
|
|
1696
|
+
// stamping a system nobody declared would stamp `undefined`, which is the
|
|
1697
|
+
// plausible lie #43 was about.
|
|
1698
|
+
if (requiresSystem !== null && !declaredSystems.has(requiresSystem)) {
|
|
1699
|
+
fail(
|
|
1700
|
+
"requiresSystem",
|
|
1701
|
+
`names \`${requiresSystem}\`, which \`systems:\` does not declare` +
|
|
1702
|
+
(declaredSystems.size ?
|
|
1703
|
+
`. Declared: ${[...declaredSystems].join(", ")}`
|
|
1704
|
+
: ` — the \`systems:\` block is empty or absent`),
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
for (const pack of packs.flatMap((p) => [p, ...p.companions])) {
|
|
1708
|
+
if (!pack.system) continue;
|
|
1709
|
+
if (declaredSystems.size && !declaredSystems.has(pack.system)) {
|
|
1710
|
+
fail(
|
|
1711
|
+
`packs.${pack.name}.system`,
|
|
1712
|
+
`names \`${pack.system}\`, which \`systems:\` does not ` +
|
|
1713
|
+
`declare. Declared: ${[...declaredSystems].join(", ")}`,
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
// With a gate set, a pack for any other system could never be seen:
|
|
1717
|
+
// Foundry drops the whole package under a system `requiresSystem` does
|
|
1718
|
+
// not name, so the pack would ship and be unreachable.
|
|
1719
|
+
if (requiresSystem !== null && pack.system !== requiresSystem) {
|
|
1720
|
+
fail(
|
|
1721
|
+
`packs.${pack.name}.system`,
|
|
1722
|
+
`names \`${pack.system}\` while \`requiresSystem\` is ` +
|
|
1723
|
+
`\`${requiresSystem}\`, so this pack could never be seen — ` +
|
|
1724
|
+
`Foundry hides the whole package from any world whose ` +
|
|
1725
|
+
`system \`requiresSystem\` does not name. Drop ` +
|
|
1726
|
+
`\`requiresSystem\`, or correct the pack`,
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1544
1731
|
// Several packs of one document type are allowed — editorial grouping of
|
|
1545
1732
|
// same-type documents is ordinary Foundry practice, and collapsing such a
|
|
1546
1733
|
// layout breaks every stored compendium UUID (#1566). What is not allowed
|
|
@@ -1592,7 +1779,50 @@ export function defineConfig(config) {
|
|
|
1592
1779
|
// one place `systems/sohl` (or `modules/sohl-thalorna`) is spelled.
|
|
1593
1780
|
assetRoot: `${packageKind}/${foundryPackage}/assets`,
|
|
1594
1781
|
paths: normalizePaths(input.paths, rootDir),
|
|
1595
|
-
|
|
1782
|
+
// The package-wide system, derived (#48). A **system** package is its
|
|
1783
|
+
// own system, which is true by construction and needs no declaration. A
|
|
1784
|
+
// **module** takes the one it requires, or the one system it declares
|
|
1785
|
+
// when there is exactly one; with several and no gate there is no
|
|
1786
|
+
// package-wide answer, and each pack carries its own.
|
|
1787
|
+
stats: normalizeStats(input.stats, {
|
|
1788
|
+
systemId:
|
|
1789
|
+
packageKind === "systems" ? foundryPackage
|
|
1790
|
+
: requiresSystem ? requiresSystem
|
|
1791
|
+
: Object.keys(systems).length === 1 ? Object.keys(systems)[0]
|
|
1792
|
+
// A lone `relationships.systems` entry is a declaration of
|
|
1793
|
+
// the system as much as a gate, so it still answers. That
|
|
1794
|
+
// matters because the relationship carries `itemCatalog`
|
|
1795
|
+
// too — a separate concern the split does not replace — so
|
|
1796
|
+
// a repository using it would otherwise have to restate its
|
|
1797
|
+
// compatibility under `systems:` purely to keep stamping,
|
|
1798
|
+
// which is the duplication this whole change exists to
|
|
1799
|
+
// remove. Several entries have no single answer and get
|
|
1800
|
+
// none.
|
|
1801
|
+
: relationshipSystems.length === 1 ?
|
|
1802
|
+
(relationshipSystems[0]?.id ?? null)
|
|
1803
|
+
: null,
|
|
1804
|
+
// Derived here where the answer is pure data — the `verified` of
|
|
1805
|
+
// whichever system the package-wide block takes — and supplied by
|
|
1806
|
+
// the loader otherwise. The loader is the half that may do I/O, and
|
|
1807
|
+
// the two cases needing it are a *system* package (its own
|
|
1808
|
+
// `package.json` version) and a module still deriving from
|
|
1809
|
+
// `relationships.systems`.
|
|
1810
|
+
systemVersion:
|
|
1811
|
+
(() => {
|
|
1812
|
+
const id =
|
|
1813
|
+
requiresSystem ??
|
|
1814
|
+
(Object.keys(systems).length === 1 ?
|
|
1815
|
+
Object.keys(systems)[0]
|
|
1816
|
+
: null);
|
|
1817
|
+
return id ?
|
|
1818
|
+
(systems[id]?.compatibility?.verified ?? null)
|
|
1819
|
+
: null;
|
|
1820
|
+
})() ??
|
|
1821
|
+
(isPlainObject(input.stats) ?
|
|
1822
|
+
input.stats[DERIVED_SYSTEM_VERSION]
|
|
1823
|
+
: null) ??
|
|
1824
|
+
null,
|
|
1825
|
+
}),
|
|
1596
1826
|
itemBuilders,
|
|
1597
1827
|
itemArt,
|
|
1598
1828
|
itemFields,
|
|
@@ -1616,6 +1846,8 @@ export function defineConfig(config) {
|
|
|
1616
1846
|
"compatibility",
|
|
1617
1847
|
),
|
|
1618
1848
|
relationships: normalizeRelationships(input.relationships),
|
|
1849
|
+
systems,
|
|
1850
|
+
requiresSystem,
|
|
1619
1851
|
packageBuild: normalizePackageBuild(input.packageBuild),
|
|
1620
1852
|
publish: normalizePublish(input.publish),
|
|
1621
1853
|
});
|
package/engine/base-compiler.mjs
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
convertNoteWikilinks,
|
|
74
74
|
collectContentDocs,
|
|
75
75
|
expandNoteTables,
|
|
76
|
+
statsForPack,
|
|
76
77
|
} from "./helpers.mjs";
|
|
77
78
|
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
78
79
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
@@ -237,6 +238,7 @@ export class BasePackCompiler {
|
|
|
237
238
|
dest,
|
|
238
239
|
folderResolver = () => null,
|
|
239
240
|
packName,
|
|
241
|
+
packSystem = null,
|
|
240
242
|
docType,
|
|
241
243
|
router,
|
|
242
244
|
routingReporter = false,
|
|
@@ -262,11 +264,34 @@ export class BasePackCompiler {
|
|
|
262
264
|
writable: false,
|
|
263
265
|
});
|
|
264
266
|
this.packName = packName;
|
|
267
|
+
this.packSystem = packSystem;
|
|
265
268
|
this.docType = docType;
|
|
266
269
|
this.router = router;
|
|
267
270
|
this.routingReporter = routingReporter;
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The `_stats` block every entry this pass emits is stamped with (#48).
|
|
275
|
+
*
|
|
276
|
+
* Per pack rather than per package, because a module may ship the same
|
|
277
|
+
* content for two systems — `harn-ensemble` has an `actors-hm3` pack and an
|
|
278
|
+
* `actors-sohl` pack — and those documents were built against different
|
|
279
|
+
* system versions. A single global block stamped both identically.
|
|
280
|
+
*
|
|
281
|
+
* Memoised on the instance: one pass, one pack, one system, so the block is
|
|
282
|
+
* constant for the life of the compiler. The previous module-level memo
|
|
283
|
+
* could not be, because it was shared across passes for different packs.
|
|
284
|
+
*
|
|
285
|
+
* @returns {object} The block, built once per compiler.
|
|
286
|
+
*/
|
|
287
|
+
get stats() {
|
|
288
|
+
this.#stats ??= statsForPack(this.packSystem);
|
|
289
|
+
return this.#stats;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @type {object|undefined} */
|
|
293
|
+
#stats;
|
|
294
|
+
|
|
270
295
|
/**
|
|
271
296
|
* Whether this pass's pack is the one a claimed note belongs in.
|
|
272
297
|
*
|
package/engine/content-links.mjs
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
canonicalKey,
|
|
59
59
|
loadForeignManifests,
|
|
60
60
|
manifestsComplete,
|
|
61
|
+
PACKAGE_BASE,
|
|
61
62
|
readCanonicalKey,
|
|
62
63
|
} from "./kb-manifest.mjs";
|
|
63
64
|
import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
|
|
@@ -336,6 +337,74 @@ export function buildLinkIndex(
|
|
|
336
337
|
*/
|
|
337
338
|
const SITE_HOST = /^(?:[a-z0-9-]+\.)*heroiclands\.org$/i;
|
|
338
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Every package landing this build can name, as `package` → base (#87).
|
|
342
|
+
*
|
|
343
|
+
* **A landing needs no manifest, and that is what makes it work.** The link
|
|
344
|
+
* manifest indexes content notes, and a homepage is deliberately not one — it
|
|
345
|
+
* compiles to no document and is entered in no manifest. The reading that
|
|
346
|
+
* follows from this, and that left a hardcoded URL as the only authored form,
|
|
347
|
+
* is that a landing therefore cannot be addressed. It does not follow: a
|
|
348
|
+
* landing's address is not a *note's* address but the **package's**, and
|
|
349
|
+
* {@link PACKAGE_BASE} already records where each package is served. That is a
|
|
350
|
+
* frozen constant vendored into every repository, so consulting it walks no
|
|
351
|
+
* tree, reads no manifest and builds no index — which is precisely why the
|
|
352
|
+
* mechanism survives `homepage` mode, where the licensing fence means none of
|
|
353
|
+
* those exist.
|
|
354
|
+
*
|
|
355
|
+
* The roster is consulted **for landings only**. Widening the package set the
|
|
356
|
+
* other rules read would make them offer manifest-based advice about packages
|
|
357
|
+
* no manifest is vendored for.
|
|
358
|
+
*
|
|
359
|
+
* @param {string} ownPackage - The package this build publishes.
|
|
360
|
+
* @param {Iterable<string>} manifestPackages - Packages a vendored manifest
|
|
361
|
+
* names, which are addressable whether or not the roster lists them.
|
|
362
|
+
* @returns {Map<string, string>} Package to base, each base slash-terminated.
|
|
363
|
+
*/
|
|
364
|
+
function landingBases(ownPackage, manifestPackages) {
|
|
365
|
+
const bases = new Map();
|
|
366
|
+
// Convention first, roster second, so a package the roster relocates is
|
|
367
|
+
// recorded at the relocated base rather than the default one.
|
|
368
|
+
for (const pkg of [ownPackage, ...manifestPackages]) {
|
|
369
|
+
if (pkg) bases.set(pkg, `/${pkg}/`);
|
|
370
|
+
}
|
|
371
|
+
for (const [pkg, base] of Object.entries(PACKAGE_BASE)) {
|
|
372
|
+
if (typeof base === "string" && base.endsWith("/")) {
|
|
373
|
+
bases.set(pkg, base);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return bases;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The package whose landing an address names, or `null`.
|
|
381
|
+
*
|
|
382
|
+
* Matches the whole path, not a prefix: `/sohl/` is the landing, `/sohl/kb/`
|
|
383
|
+
* is a page inside the package and belongs to the manifest rules instead.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} url - The authored address.
|
|
386
|
+
* @param {Map<string, string>} bases - From {@link landingBases}.
|
|
387
|
+
* @returns {{pkg: string, base: string}|null} The package and its base.
|
|
388
|
+
*/
|
|
389
|
+
function landingTarget(url, bases) {
|
|
390
|
+
const value = String(url ?? "").trim();
|
|
391
|
+
if (!value || !/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
|
|
392
|
+
let parsed;
|
|
393
|
+
try {
|
|
394
|
+
parsed = new URL(value);
|
|
395
|
+
} catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
if (!/^https?:$/.test(parsed.protocol)) return null;
|
|
399
|
+
if (!SITE_HOST.test(parsed.hostname)) return null;
|
|
400
|
+
const pathname =
|
|
401
|
+
parsed.pathname.endsWith("/") ? parsed.pathname : `${parsed.pathname}/`;
|
|
402
|
+
for (const [pkg, base] of bases) {
|
|
403
|
+
if (pathname === base) return { pkg, base };
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
339
408
|
/**
|
|
340
409
|
* How an authored address resolves, or `null` for one nothing here can judge.
|
|
341
410
|
*
|
|
@@ -400,9 +469,17 @@ function readAddress(url, packages) {
|
|
|
400
469
|
* and what replaced it, so this is a fact rather than a guess — and it is
|
|
401
470
|
* exactly the SoHL defect.
|
|
402
471
|
* - A **hardcoded absolute URL** into this package's own prefix, or into one a
|
|
403
|
-
* vendored manifest names.
|
|
404
|
-
*
|
|
405
|
-
*
|
|
472
|
+
* vendored manifest names. Every one of them has a better form to write, which
|
|
473
|
+
* is why every one is reported — including a bare `/<package>/`, which names
|
|
474
|
+
* another package's landing (#87).
|
|
475
|
+
*
|
|
476
|
+
* That last case was exempt until the better form was identified, on the
|
|
477
|
+
* reasoning that a landing is in no link manifest so nothing could resolve it.
|
|
478
|
+
* True, and beside the point: it does not need resolving. A landing's address
|
|
479
|
+
* *is* its package prefix, so `/<package>/` is the absolute URL with the host
|
|
480
|
+
* struck off — host-free, emitted verbatim, and needing no index, which is
|
|
481
|
+
* what lets it hold in homepage-only mode where the tree is never walked. The
|
|
482
|
+
* form was already accepted here; nothing had ever named it as the one to use.
|
|
406
483
|
* - A **root-relative `url:`**, which the theme's `relURL` prefixes a second
|
|
407
484
|
* time. `href:` means "already resolved, use verbatim", so the same leading
|
|
408
485
|
* slash is correct there and is not reported.
|
|
@@ -424,6 +501,7 @@ function readAddress(url, packages) {
|
|
|
424
501
|
export function auditHomepageLinks(index) {
|
|
425
502
|
const findings = [];
|
|
426
503
|
const packages = new Set([index.contentPackage, ...index.packages]);
|
|
504
|
+
const bases = landingBases(index.contentPackage, index.packages);
|
|
427
505
|
|
|
428
506
|
for (const note of index.notes) {
|
|
429
507
|
if (!isHomepage(note.fm)) continue;
|
|
@@ -467,28 +545,43 @@ export function auditHomepageLinks(index) {
|
|
|
467
545
|
if (!address) continue;
|
|
468
546
|
const { shape, segments, prefix } = address;
|
|
469
547
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
548
|
+
// Landings first, and by the roster rather than by the manifest
|
|
549
|
+
// package set: a landing is addressable in a repository that
|
|
550
|
+
// vendors no manifest at all, which is the case the fence creates
|
|
551
|
+
// and the case this rule exists for (#87).
|
|
552
|
+
const landing = landingTarget(url, bases);
|
|
553
|
+
if (landing) {
|
|
554
|
+
report(
|
|
555
|
+
field,
|
|
556
|
+
url,
|
|
557
|
+
url,
|
|
558
|
+
occurrence,
|
|
559
|
+
`hardcoded absolute URL to ` +
|
|
560
|
+
(landing.pkg === index.contentPackage ?
|
|
561
|
+
`this package's own landing`
|
|
562
|
+
: `package "${landing.pkg}"'s landing`) +
|
|
563
|
+
` — write "${landing.base}", which names no host, is ` +
|
|
564
|
+
`emitted verbatim, and resolves through the package ` +
|
|
565
|
+
`roster rather than through an index, so it holds ` +
|
|
566
|
+
`where no content tree is walked`,
|
|
567
|
+
);
|
|
568
|
+
} else if (shape === "absolute" && prefix) {
|
|
474
569
|
const rest = segments.slice(1).join("/");
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
`
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
);
|
|
491
|
-
}
|
|
570
|
+
report(
|
|
571
|
+
field,
|
|
572
|
+
url,
|
|
573
|
+
url,
|
|
574
|
+
occurrence,
|
|
575
|
+
prefix === index.contentPackage ?
|
|
576
|
+
`hardcoded absolute URL into this package's own ` +
|
|
577
|
+
`address — write the package-relative ` +
|
|
578
|
+
`"${rest}/", which the landing resolves ` +
|
|
579
|
+
`against the site so the page follows the mount`
|
|
580
|
+
: `hardcoded absolute URL into package "${prefix}" ` +
|
|
581
|
+
`— resolve it through that package's link ` +
|
|
582
|
+
`manifest, whose entries carry the address, so a ` +
|
|
583
|
+
`relocation does not leave this page behind`,
|
|
584
|
+
);
|
|
492
585
|
} else if (shape === "rooted" && kind === "url") {
|
|
493
586
|
const rest =
|
|
494
587
|
prefix ? segments.slice(1).join("/") : segments.join("/");
|
|
@@ -497,9 +590,21 @@ export function auditHomepageLinks(index) {
|
|
|
497
590
|
url,
|
|
498
591
|
url,
|
|
499
592
|
occurrence,
|
|
500
|
-
`url
|
|
501
|
-
|
|
502
|
-
|
|
593
|
+
// A `url:` is package-relative by construction, so it
|
|
594
|
+
// cannot address anything outside this package at all —
|
|
595
|
+
// there is no relative spelling of another package's root.
|
|
596
|
+
// `href:` is the field for an address already resolved.
|
|
597
|
+
!rest ?
|
|
598
|
+
`url "${url}" addresses ` +
|
|
599
|
+
(prefix ?
|
|
600
|
+
`package "${prefix}"'s landing`
|
|
601
|
+
: `the site root`) +
|
|
602
|
+
`, but a landing's url: is package-relative and ` +
|
|
603
|
+
`cannot leave this package — write ` +
|
|
604
|
+
`href: "${url}", which is used verbatim`
|
|
605
|
+
: `url "${url}" is root-relative, but a landing's url: ` +
|
|
606
|
+
`is resolved against the site — write "${rest}/", ` +
|
|
607
|
+
`or href: for an address that is already resolved`,
|
|
503
608
|
);
|
|
504
609
|
}
|
|
505
610
|
|