@heroiclands/package-build 22.0.3 → 22.1.1

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.
@@ -86,7 +86,7 @@ import {
86
86
  PACKAGE_BASE,
87
87
  readCanonicalKey,
88
88
  } from "./content-address.mjs";
89
- import { loadForeignIndexes } from "./metadata-index.mjs";
89
+ import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
90
90
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
91
91
  import { homepageAddresses, isHomepage } from "./homepage.mjs";
92
92
  import { RETIRED_TYPES } from "./ids.mjs";
@@ -268,6 +268,10 @@ export function buildLinkIndex(
268
268
  for (const v of foreign.index.values()) if (v.type) types.add(v.type);
269
269
 
270
270
  const packages = new Set([...(byKey.size ? [pkg] : []), ...foreign.packages]);
271
+ // Packages declared `contentIndex: false` — a Foundry dependency only, with
272
+ // no fetched index. A link naming one is refused with a diagnostic that
273
+ // names the key, rather than reading as an undeclared package or a typo.
274
+ const noIndexPackages = config ? noContentIndexPackages(config) : new Set();
271
275
 
272
276
  // The address space an `![[…]]` embed resolves against, shaped as every
273
277
  // other asset resolver reads one so the checker cannot answer an authored
@@ -464,7 +468,7 @@ export function buildLinkIndex(
464
468
  * @returns {object|undefined} The note it addresses.
465
469
  */
466
470
  function resolveAddress(target, keyPath) {
467
- const qualified = readQualifier(target, types, packages);
471
+ const qualified = readQualifier(target, types, packages, noIndexPackages);
468
472
  if (!qualified || qualified.reason) return undefined;
469
473
  // Every omitted segment defaults from where the link is written,
470
474
  // so the target expands to exactly one canonical address and this is a
@@ -501,7 +505,7 @@ export function buildLinkIndex(
501
505
  * @returns {object[]} The foreign entries, each carrying its `package`.
502
506
  */
503
507
  function foreignHits(target, keyPath) {
504
- const q = readQualifier(target, types, packages);
508
+ const q = readQualifier(target, types, packages, noIndexPackages);
505
509
  if (!q || q.reason) return [];
506
510
  // An omitted package means *this* package, so a short form
507
511
  // addresses nothing foreign and never reaches a dependency's index.
@@ -552,7 +556,7 @@ export function buildLinkIndex(
552
556
  * @returns {object|null} The note, asset record or foreign entry declaring it.
553
557
  */
554
558
  function referenceHit(target) {
555
- const q = readQualifier(target, types, packages);
559
+ const q = readQualifier(target, types, packages, noIndexPackages);
556
560
  if (!q || q.reason) return null;
557
561
  const local = matchAddress([...byKey, ...byAssetKey], q);
558
562
  if (local.length) return local[0][1];
@@ -566,6 +570,8 @@ export function buildLinkIndex(
566
570
  anchors,
567
571
  types,
568
572
  packages,
573
+ /** Packages declared `contentIndex: false`, a Foundry dependency only. */
574
+ noIndexPackages,
569
575
  /**
570
576
  * The files this package ships, by canonical address. Separate from the
571
577
  * notes because the two record shapes are read differently, and exposed
@@ -597,7 +603,7 @@ export function buildLinkIndex(
597
603
  foreignHits,
598
604
  referenceHit,
599
605
  /** Whether a target reads as a qualified address at all. */
600
- isAddress: (target) => Boolean(readQualifier(target, types, packages)),
606
+ isAddress: (target) => Boolean(readQualifier(target, types, packages, noIndexPackages)),
601
607
  };
602
608
  }
603
609
 
@@ -922,8 +928,9 @@ export function auditHomepageLinks(index) {
922
928
  * which addresses a foreign manifest answered. Each `deadAddresses` entry
923
929
  * carries a `reason` from {@link LINK_FINDING_REASONS} —
924
930
  * `"not-an-address"`, `"unknown-type"`, `"ambiguous"` (with the claiming
925
- * `packages`), or `"unresolved"` — and every one of them is an **error**:
926
- * the three resolvers agree on severity for every class.
931
+ * `packages`), `"no-content-index"`, or `"unresolved"` — and every one of
932
+ * them is an **error**: the three resolvers agree on severity for every
933
+ * class.
927
934
  */
928
935
  export function auditLinks(index) {
929
936
  const { notes, anchors, linksOf, embedsOf, resolve, manifestHit, isAddress } = index;
@@ -1004,10 +1011,13 @@ export function auditLinks(index) {
1004
1011
  });
1005
1012
  continue;
1006
1013
  }
1007
- const read = readQualifier(target, index.types, index.packages);
1014
+ const read = readQualifier(target, index.types, index.packages, index.noIndexPackages);
1008
1015
  deadAddresses.push({
1009
1016
  ...at,
1010
- reason: read?.reason === "unknown-type" ? "unknown-type" : "unresolved",
1017
+ reason:
1018
+ read?.reason === "unknown-type" ? "unknown-type"
1019
+ : read?.reason === "no-content-index" ? "no-content-index"
1020
+ : "unresolved",
1011
1021
  });
1012
1022
  }
1013
1023
  }
@@ -43,7 +43,7 @@ import { contentPackage, foundryPackageId } from "./content-package.mjs";
43
43
  import { searchableFrontmatter } from "./note-package.mjs";
44
44
  import { PACKAGE_BASE } from "./content-address.mjs";
45
45
  import { resolveNoteId } from "./note-ids.mjs";
46
- import { loadForeignIndexes } from "./metadata-index.mjs";
46
+ import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
47
47
  // The record accessors only — deriving records reaches the pack router and the
48
48
  // manifest emitter, which reach the compilers, which load this module. Reading
49
49
  // a record needs none of that.
@@ -787,6 +787,7 @@ export function buildContentLinkIndex(
787
787
  );
788
788
  return buildWikilinkIndex(docs, resolved.foundryPackage, foreign, resolved.contentPackage, {
789
789
  assets,
790
+ noIndexPackages: noContentIndexPackages(resolved),
790
791
  });
791
792
  }
792
793
 
@@ -84,6 +84,11 @@ export const METADATA_RELATIONSHIP_KINDS = Object.freeze(["systems", "requires"]
84
84
  * and needing no items is the mirror of it. Gating the index on the catalogue
85
85
  * flag would serve neither.
86
86
  *
87
+ * **Excludes a relationship declaring `contentIndex: false`.** That opts a
88
+ * dependency out of both edges at once: it is a Foundry dependency only, cited
89
+ * by neither a wikilink nor an item reference, so there is nothing here for
90
+ * `deps fetch` to fill and no cache this build will ever read.
91
+ *
87
92
  * The declaration is the one already in the emitted `system.json` /
88
93
  * `module.json`, so it cannot drift from what Foundry itself installs, and
89
94
  * there is no new configuration key to keep in step. Each entry carries the
@@ -98,6 +103,7 @@ export function metadataRelationships(config) {
98
103
  const out = [];
99
104
  for (const kind of METADATA_RELATIONSHIP_KINDS) {
100
105
  for (const rel of config?.relationships?.[kind] ?? []) {
106
+ if (rel.contentIndex === false) continue;
101
107
  out.push({
102
108
  id: rel.id,
103
109
  manifest: rel.manifest,
@@ -109,6 +115,32 @@ export function metadataRelationships(config) {
109
115
  return out;
110
116
  }
111
117
 
118
+ /**
119
+ * Every package a relationship declares `contentIndex: false` on, keyed by
120
+ * the content package name a link into it would use.
121
+ *
122
+ * A separate set from {@link metadataRelationships}, which answers "what does
123
+ * `deps fetch` fill" — this answers "what does the link resolver recognise as
124
+ * a package with no fetched index", which a wikilink checker or pack compiler
125
+ * needs to tell that case apart from a package nobody declared at all.
126
+ *
127
+ * Walked across every relationship kind, not only the citable ones: the
128
+ * config validation refuses the flag nowhere by kind, so a resolver reading it
129
+ * back should not assume one either.
130
+ *
131
+ * @param {object} config - The resolved build configuration.
132
+ * @returns {ReadonlySet<string>} The content package names.
133
+ */
134
+ export function noContentIndexPackages(config) {
135
+ const out = new Set();
136
+ for (const entries of Object.values(config?.relationships ?? {})) {
137
+ for (const rel of entries ?? []) {
138
+ if (rel.contentIndex === false) out.add(rel.contentPackage ?? rel.id);
139
+ }
140
+ }
141
+ return Object.freeze(out);
142
+ }
143
+
112
144
  /**
113
145
  * The cache directory for one dependency's index at one version.
114
146
  *
@@ -496,6 +496,26 @@ export function hasTag(fm, tag) {
496
496
  return false;
497
497
  }
498
498
 
499
+ /**
500
+ * Whether a note carries any `tags:` at all, however authored.
501
+ *
502
+ * The one question the site build asks of tags in aggregate — whether the
503
+ * tree publishes taxonomy pages — rather than about a particular tag. Reads
504
+ * `tags` and `tag` exactly as {@link hasTag} does, and treats an empty list
505
+ * or a blank string as carrying none.
506
+ *
507
+ * @param {object|null|undefined} fm - Parsed frontmatter.
508
+ * @returns {boolean} Whether the note carries at least one tag.
509
+ */
510
+ export function hasAnyTag(fm) {
511
+ const raw = fm?.tags ?? fm?.tag;
512
+ if (raw == null) return false;
513
+ for (const entry of Array.isArray(raw) ? raw : [raw]) {
514
+ if (typeof entry === "string" && entry.trim() !== "") return true;
515
+ }
516
+ return false;
517
+ }
518
+
499
519
  /**
500
520
  * Whether a note is tagged as an unfinished **draft**.
501
521
  *
@@ -267,8 +267,8 @@ function readPackageJson(rootDir) {
267
267
  } catch (err) {
268
268
  throw new Error(
269
269
  `package-build: ${manifestPath} could not be read, and the ` +
270
- `configuration derives both the Foundry package id and the ` +
271
- `system version from it.`,
270
+ `configuration derives its Foundry package id, system version, ` +
271
+ `homepage and author from it.`,
272
272
  { cause: err },
273
273
  );
274
274
  }
@@ -555,6 +555,25 @@ export function configFromData(data, configPath) {
555
555
  input.foundryPackage = foundryPackageId(rootDir);
556
556
  }
557
557
 
558
+ // `homepage` and `author` are the same kind of fact, transcribed the same
559
+ // way, and read regardless of `packageKind`: a documentation package
560
+ // publishes a site too, and needs both for it.
561
+ for (const [field, label] of [
562
+ ["homepage", "`homepage`"],
563
+ ["author", "`author`"],
564
+ ]) {
565
+ if (input[field] !== undefined) {
566
+ throw new Error(
567
+ `package-build: ${configPath} declares \`${field}\`, which a data ` +
568
+ `configuration may not: it is \`package.json\`'s own ${label}. ` +
569
+ `Remove the key.`,
570
+ );
571
+ }
572
+ }
573
+ const { pkg } = readPackageJson(rootDir);
574
+ if (pkg.homepage !== undefined) input.homepage = pkg.homepage;
575
+ if (pkg.author !== undefined) input.author = pkg.author;
576
+
558
577
  if (input.itemBuilders !== undefined) {
559
578
  const declared = input.itemBuilders;
560
579
  const known = Object.keys(ITEM_BUILDER_REGISTRIES).join(", ");
@@ -57,13 +57,14 @@ import { renderImageFigures } from "./content-images.mjs";
57
57
  import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
58
58
  import { buildSiteIndex, resolveInfoboxRef, wikiContext } from "./site-index.mjs";
59
59
  import { frontmatterWikilinks, resolveWebWikilinks } from "./web-wikilinks.mjs";
60
- import { loadForeignIndexes } from "./metadata-index.mjs";
60
+ import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
61
61
  import { noteInfoboxes } from "./infobox-registry.mjs";
62
62
  import { formatUnaddressableFinding, unaddressableForeignPackages } from "./metadata-index.mjs";
63
63
  import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
64
64
  import { loadPackConfig } from "./pack-config.mjs";
65
65
  import { routerFor } from "./pack-router.mjs";
66
66
  import { searchableFrontmatter } from "./note-package.mjs";
67
+ import { hasAnyTag } from "./note-vocabulary.mjs";
67
68
  // The corpus, from the one pass that derives it.
68
69
  import { indexRecordsFor } from "./content-index.mjs";
69
70
  import { isNoteRecord, noteFile } from "./index-records.mjs";
@@ -76,6 +77,7 @@ import {
76
77
  isHomepage,
77
78
  } from "./homepage.mjs";
78
79
  import { publishesContentPages } from "../content-config.mjs";
80
+ import { HUGO_CONTENT } from "./site-config.mjs";
79
81
 
80
82
  const require = createRequire(import.meta.url);
81
83
 
@@ -397,8 +399,8 @@ export function collectHomepages(contentBase, ctx) {
397
399
  * has. Nothing is written at `/<package>/` itself: that becomes a redirect the
398
400
  * package's own repository authors, which is a routing fact rather than a page.
399
401
  *
400
- * @param {string} outRoot - The package's site root — the configured `site.out`,
401
- * one level above the content mount.
402
+ * @param {string} outRoot - The package's site root — the content mount's
403
+ * root, `build/hugo/content`, one level above the mount itself.
402
404
  * @param {readonly object[]} pages - From {@link collectHomepages}.
403
405
  * @param {object} config - The resolved configuration, for the package name and
404
406
  * the default title.
@@ -475,7 +477,10 @@ export function siteGates(pages, findings, { config }) {
475
477
  out.unaddressable = unaddressableForeignPackages(foreign.index);
476
478
  if (out.unaddressable.length) return out;
477
479
 
478
- const index = buildSiteIndex(pages, { foreignIndex: foreign.index });
480
+ const index = buildSiteIndex(pages, {
481
+ foreignIndex: foreign.index,
482
+ noIndexPackages: noContentIndexPackages(config),
483
+ });
479
484
  out.conflicts = index.conflicts;
480
485
  if (out.conflicts.length) return out;
481
486
 
@@ -1025,46 +1030,6 @@ export function resolveSitePass(name, options) {
1025
1030
  return factory()(options);
1026
1031
  }
1027
1032
 
1028
- /**
1029
- * The output root, having established that it is safe to delete.
1030
- *
1031
- * The whole tree is a build artifact and is wiped on every run, so this
1032
- * resolution is the difference between clearing a build directory and clearing
1033
- * the repository. An unset `site.out` resolves to `rootDir` itself, and the
1034
- * wipe then deletes the working tree — which is not a hypothetical: it happened
1035
- * while this module was being written, on a configuration that simply had no
1036
- * `site` section yet.
1037
- *
1038
- * So the path is refused unless it is **strictly inside** the repository root.
1039
- * Both failing shapes are ordinary rather than exotic — an absent setting, and a
1040
- * `..` that climbs out — and neither should be recoverable by being careful.
1041
- *
1042
- * @param {string} rootDir - The repository root.
1043
- * @param {string} out - The configured `site.out`.
1044
- * @returns {string} The absolute output root.
1045
- * @throws {Error} When it is unset, or is not below `rootDir`.
1046
- */
1047
- export function resolveOutputRoot(rootDir, out) {
1048
- if (!out) {
1049
- throw new Error(
1050
- "site.out is not set, so there is nowhere to write the site. " +
1051
- "Refusing to continue: the output directory is wiped on every " +
1052
- "run, and an unset one resolves to the repository root.",
1053
- );
1054
- }
1055
- const root = path.resolve(rootDir);
1056
- const resolved = path.resolve(root, out);
1057
- const inside = resolved !== root && resolved.startsWith(root + path.sep);
1058
- if (!inside) {
1059
- throw new Error(
1060
- `site.out (${JSON.stringify(out)}) resolves to ${resolved}, which ` +
1061
- `is not inside ${root}. Refusing to continue: that directory ` +
1062
- `is wiped on every run.`,
1063
- );
1064
- }
1065
- return resolved;
1066
- }
1067
-
1068
1033
  /**
1069
1034
  * Builds a Hugo content tree from a content tree, and reports what it found.
1070
1035
  *
@@ -1076,16 +1041,18 @@ export function resolveOutputRoot(rootDir, out) {
1076
1041
  * @param {object} [options] - Options.
1077
1042
  * @param {object} [options.config] - A resolved configuration; loaded when
1078
1043
  * omitted.
1079
- * @param {string} [options.outRoot] - Override the configured output mount.
1080
1044
  * @param {Map<string, object[]>} [options.sqlTables] - Prepared `sql` results,
1081
1045
  * keyed by the note's absolute file, from
1082
1046
  * {@link module:engine/sql-tables.prepareSqlTables}. A page authoring an
1083
1047
  * `sql` directive with none prepared is a table error: nothing here runs a
1084
1048
  * query.
1085
1049
  * @returns {{gates: object, stats: object|null, tableErrors: object[],
1086
- * wikiErrors: object[], imageErrors: object[], manifests: object|null}}
1050
+ * wikiErrors: object[], imageErrors: object[], manifests: object|null,
1051
+ * hasTags: boolean}} `hasTags` is whether any note the walk read carries
1052
+ * `tags:` — what {@link module:engine/site-config.hugoConfig} reads to
1053
+ * decide whether the site emits taxonomy pages.
1087
1054
  */
1088
- export function buildSite({ config, outRoot, sqlTables } = {}) {
1055
+ export function buildSite({ config, sqlTables } = {}) {
1089
1056
  const resolved = config ?? loadPackConfig();
1090
1057
  const site = resolved.site;
1091
1058
  const scheme = resolved.publish.address;
@@ -1104,16 +1071,19 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1104
1071
 
1105
1072
  // The Hugo content tree mirrors that mount: a page written to
1106
1073
  // `<out>/<prefix>/<section>/` publishes at `<base><prefix><section>/`.
1107
- // Resolved against the repository root for the same reason every configured
1108
- // path is so the build reads and writes the same places whatever
1109
- // directory it was launched from.
1110
- const outBase = resolveOutputRoot(resolved.rootDir, site.out);
1074
+ // The root is fixed `build/hugo/content`, beside the generated
1075
+ // `hugo.toml`and resolved against the repository root for the same
1076
+ // reason every configured path is, so the build reads and writes the same
1077
+ // places whatever directory it was launched from. It is wiped on every
1078
+ // run, which is safe precisely because it is not configurable: nothing an
1079
+ // author writes can point it at the working tree.
1080
+ const outBase = path.join(resolved.rootDir, HUGO_CONTENT);
1111
1081
  const out =
1112
- outRoot ? path.resolve(outRoot)
1113
- : publishesContent ? path.join(outBase, scheme.prefix.replace(/\/$/, ""))
1082
+ publishesContent ?
1083
+ path.join(outBase, scheme.prefix.replace(/\/$/, ""))
1114
1084
  // Homepage-only has no content mount, so the package's root *is*
1115
- // the output root and `--out` redirects the whole of it.
1116
- : outBase;
1085
+ // the output root.
1086
+ : outBase;
1117
1087
  // The homepage publishes at `/<contentPackage>/<type>-<shortcode>/`, so its
1118
1088
  // file goes at the package's own root — one level above the content mount,
1119
1089
  // and the same directory in homepage-only mode.
@@ -1179,6 +1149,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1179
1149
  wikiErrors: [],
1180
1150
  imageErrors: [],
1181
1151
  stats: null,
1152
+ hasTags: homepages.some((p) => hasAnyTag(p.fm)),
1182
1153
  };
1183
1154
  }
1184
1155
 
@@ -1201,6 +1172,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1201
1172
  landings: 0,
1202
1173
  out: homeRoot,
1203
1174
  },
1175
+ hasTags: homepages.some((p) => hasAnyTag(p.fm)),
1204
1176
  };
1205
1177
  }
1206
1178
 
@@ -1248,6 +1220,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1248
1220
  tableErrors: [],
1249
1221
  wikiErrors: [],
1250
1222
  imageErrors: [],
1223
+ hasTags: [...pages, ...homepageEntries].some((p) => hasAnyTag(p.fm)),
1251
1224
  };
1252
1225
  }
1253
1226
 
@@ -1306,6 +1279,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1306
1279
  landings,
1307
1280
  out,
1308
1281
  },
1282
+ hasTags: [...pages, ...homepageEntries].some((p) => hasAnyTag(p.fm)),
1309
1283
  };
1310
1284
  }
1311
1285