@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +840 -0
  2. package/CONTENT.md +21 -1
  3. package/bin/content-build.mjs +105 -10
  4. package/bin/package-build.mjs +114 -1
  5. package/config.mjs +62 -3
  6. package/content-config.mjs +254 -22
  7. package/engine/base-compiler.mjs +25 -0
  8. package/engine/content-links.mjs +132 -27
  9. package/engine/diagnostics.mjs +61 -1
  10. package/engine/foreign-catalog.mjs +47 -0
  11. package/engine/generate.mjs +10 -5
  12. package/engine/helpers.mjs +38 -0
  13. package/engine/journals.mjs +8 -1
  14. package/engine/macros.mjs +2 -0
  15. package/engine/pack-config.mjs +143 -13
  16. package/engine/prose-lint.mjs +10 -2
  17. package/engine/scenes.mjs +2 -2
  18. package/engine/schema-check.mjs +332 -0
  19. package/engine/schema-extract.mjs +611 -0
  20. package/engine/web-wikilinks.mjs +13 -4
  21. package/engine/wikilink-syntax.mjs +25 -0
  22. package/engine/wikilinks.mjs +6 -3
  23. package/manifest.mjs +37 -2
  24. package/package.json +5 -3
  25. package/sohl/actors.mjs +23 -10
  26. package/sohl/item-fields.mjs +0 -35
  27. package/sohl/items.mjs +1 -1
  28. package/types/content-config.d.mts +14 -0
  29. package/types/engine/base-compiler.d.mts +18 -1
  30. package/types/engine/content-links.d.mts +11 -3
  31. package/types/engine/diagnostics.d.mts +33 -1
  32. package/types/engine/foreign-catalog.d.mts +15 -0
  33. package/types/engine/generate.d.mts +3 -2
  34. package/types/engine/helpers.d.mts +29 -3
  35. package/types/engine/journals.d.mts +7 -1
  36. package/types/engine/pack-config.d.mts +22 -0
  37. package/types/engine/prose-lint.d.mts +10 -2
  38. package/types/engine/schema-check.d.mts +176 -0
  39. package/types/engine/schema-extract.d.mts +61 -0
  40. package/types/engine/wikilink-syntax.d.mts +24 -0
  41. package/types/sohl/actors.d.mts +3 -3
@@ -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 = ["systemId", "systemVersion", "lastModifiedBy"];
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
- * @param {string} field
641
- * @param {string} problem
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 new TypeError(`package-build config: \`${field}\` ${problem}.`);
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
- // Optional: a package whose packs are not all for one system declares
879
- // the system per pack instead (`packs[].system`), and a package that
880
- // ships only system-agnostic documents declares none at all.
881
- systemId:
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
- stats: normalizeStats(input.stats),
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
  });
@@ -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
  *
@@ -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. 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.
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
- 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.
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
- 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
- }
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 "${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`,
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