@heroiclands/package-build 22.1.0 → 22.2.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.
@@ -44,7 +44,7 @@ import fs from "node:fs";
44
44
  import path from "node:path";
45
45
  import { stringify as stringifyToml } from "smol-toml";
46
46
 
47
- import { checkHomepage } from "../config.mjs";
47
+ import { checkHomepage, fail } from "../config.mjs";
48
48
  import { slugify } from "./content-slug.mjs";
49
49
 
50
50
  /** The Hugo source directory, relative to the repository root. */
@@ -83,14 +83,39 @@ export const BRAND = Object.freeze({
83
83
  });
84
84
 
85
85
  /**
86
- * The kinds no site renders.
86
+ * The kinds a site with no tagged notes renders.
87
87
  *
88
88
  * A section exists only where `site.sections` declares one, so a tree holding
89
- * only the homepage emits nothing beyond it; taxonomies and feeds would be
90
- * empty shells on every site.
89
+ * only the homepage emits nothing beyond it; a taxonomy nobody's notes fill
90
+ * and a feed would be empty shells. A site whose notes carry `tags:` emits
91
+ * `taxonomy` and `term` after all — see {@link hugoConfig} — but `RSS` is
92
+ * disabled either way: nothing here publishes a feed.
91
93
  */
92
94
  export const DISABLE_KINDS = Object.freeze(["taxonomy", "term", "RSS"]);
93
95
 
96
+ /**
97
+ * The kinds a site with at least one tagged note renders — everything but
98
+ * `RSS`.
99
+ */
100
+ const DISABLE_KINDS_TAGGED = Object.freeze(["RSS"]);
101
+
102
+ /**
103
+ * The single taxonomy a tagged site declares.
104
+ *
105
+ * Only `tag` — Hugo's default pair also declares `category`, which nothing
106
+ * here authors and which would publish an empty `/categories/`.
107
+ */
108
+ const TAXONOMIES = Object.freeze({ tag: "tags" });
109
+
110
+ /**
111
+ * The taxonomy output formats a tagged site declares — `HTML` only, so no
112
+ * feed is produced for `/tags/` or a single tag.
113
+ */
114
+ const TAXONOMY_OUTPUTS = Object.freeze({
115
+ taxonomy: Object.freeze(["HTML"]),
116
+ term: Object.freeze(["HTML"]),
117
+ });
118
+
94
119
  /**
95
120
  * The markup settings the toolchain's own output requires.
96
121
  *
@@ -356,14 +381,17 @@ function deepMerge(base, overrides) {
356
381
  *
357
382
  * @param {object} options - The sources.
358
383
  * @param {object} options.config - The resolved build configuration.
359
- * @param {string} [options.description] - `package.json`'s `description`.
360
384
  * @param {readonly NavigationEntry[]} options.navigation - The navigation.
361
385
  * @param {string} options.themesDir - From {@link resolveThemesDir}.
386
+ * @param {boolean} [options.hasTags] - Whether any note the site build walked
387
+ * carries `tags:`, from {@link module:engine/site-build.buildSite}'s
388
+ * `hasTags`. Defaults to `false` — no tagged note, no taxonomy pages.
362
389
  * @returns {Record<string, any>} The configuration Hugo reads.
363
390
  * @throws {TypeError} When `homepage` fails `checkHomepage`, or the
364
- * configuration declares no `packageBuild.manifest.title`.
391
+ * configuration declares no `packageBuild.manifest.title`, no
392
+ * `site.description`, or no `site.assets`.
365
393
  */
366
- export function hugoConfig({ config, description, navigation, themesDir }) {
394
+ export function hugoConfig({ config, navigation, themesDir, hasTags = false }) {
367
395
  checkHomepage(config.homepage, config.contentPackage);
368
396
 
369
397
  const title = config.packageBuild?.manifest?.title;
@@ -373,10 +401,23 @@ export function hugoConfig({ config, description, navigation, themesDir }) {
373
401
  "and the site's `title` reads from it.",
374
402
  );
375
403
  }
404
+ if (!config.site.description) {
405
+ fail(
406
+ "site.description",
407
+ 'is not declared, and the site\'s `<meta name="description">` reads from it',
408
+ );
409
+ }
410
+ if (!config.site.assets) {
411
+ fail(
412
+ "site.assets",
413
+ "is not declared, and a site build needs one — it is the host every " +
414
+ "package's imagery is served from, and the theme resolves every " +
415
+ "relative asset against it",
416
+ );
417
+ }
376
418
 
377
419
  /** @type {Record<string, unknown>} */
378
- const params = {};
379
- if (typeof description === "string" && description.trim()) params.description = description;
420
+ const params = { description: config.site.description };
380
421
  if (config.author?.name) params.author = config.author.name;
381
422
  if (config.site.assets) params.cdnBaseURL = config.site.assets;
382
423
  params.brand = { ...BRAND };
@@ -391,11 +432,18 @@ export function hugoConfig({ config, description, navigation, themesDir }) {
391
432
  themesDir,
392
433
  theme: THEME,
393
434
  contentDir: path.posix.relative(HUGO_SOURCE, HUGO_CONTENT),
394
- disableKinds: [...DISABLE_KINDS],
435
+ disableKinds: hasTags ? [...DISABLE_KINDS_TAGGED] : [...DISABLE_KINDS],
395
436
  params,
396
437
  markup: structuredClone(MARKUP),
397
438
  menu: { main: menuEntries(navigation) },
398
439
  };
440
+ if (hasTags) {
441
+ generated.taxonomies = { ...TAXONOMIES };
442
+ generated.outputs = {
443
+ taxonomy: [...TAXONOMY_OUTPUTS.taxonomy],
444
+ term: [...TAXONOMY_OUTPUTS.term],
445
+ };
446
+ }
399
447
  return deepMerge(generated, config.site.hugo);
400
448
  }
401
449
 
@@ -414,35 +462,28 @@ export function hugoToml(generated) {
414
462
  );
415
463
  }
416
464
 
417
- /**
418
- * `package.json`'s `description`, or `undefined` when it declares none.
419
- *
420
- * @param {string} rootDir - The repository root.
421
- * @returns {string|undefined} The description.
422
- */
423
- function packageDescription(rootDir) {
424
- const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
425
- return typeof pkg.description === "string" ? pkg.description : undefined;
426
- }
427
-
428
465
  /**
429
466
  * The Hugo configuration, every source read from the repository.
430
467
  *
431
- * Reads `package.json`, the cached navigation and the installed theme's
432
- * location, and composes them with {@link hugoConfig}. Nothing is written, so
433
- * a caller can run this before touching the output tree and fail with it
434
- * intact.
468
+ * Reads the cached navigation and the installed theme's location, and
469
+ * composes them with {@link hugoConfig}. Nothing is written, so a caller can
470
+ * run this before touching the output tree and fail with it intact.
435
471
  *
436
472
  * @param {object} config - The resolved build configuration.
473
+ * @param {object} [options] - Options.
474
+ * @param {boolean} [options.hasTags] - Whether any note the site build walked
475
+ * carries `tags:`. Defaults to `false`, so a caller generating the
476
+ * configuration before the walk (to fail fast on a missing source) gets the
477
+ * untagged shape; pass the site build's own `hasTags` once it is known.
437
478
  * @returns {Record<string, any>} The configuration Hugo reads.
438
479
  * @throws {Error} When any source is missing or wrong.
439
480
  */
440
- export function generateHugoConfig(config) {
481
+ export function generateHugoConfig(config, { hasTags = false } = {}) {
441
482
  return hugoConfig({
442
483
  config,
443
- description: packageDescription(config.rootDir),
444
484
  navigation: readCachedNavigation(config),
445
485
  themesDir: resolveThemesDir(config.rootDir),
486
+ hasTags,
446
487
  });
447
488
  }
448
489
 
@@ -166,9 +166,16 @@ function mergeForeign(index, foreignIndex) {
166
166
  * @param {Map<string, {package: string, type?: string}>} [options.foreignIndex]
167
167
  * The merged index from `loadForeignIndexes`. Omit when the build publishes
168
168
  * no cross-package links.
169
+ * @param {Set<string>} [options.noIndexPackages] - Packages declared
170
+ * `contentIndex: false` — a Foundry dependency only, with no fetched index.
171
+ * A link naming one fails naming the key, rather than reading as prose or an
172
+ * ordinary dead address.
169
173
  * @returns {SiteIndex} The index, and what could not be addressed unambiguously.
170
174
  */
171
- export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
175
+ export function buildSiteIndex(
176
+ entries,
177
+ { foreignIndex = new Map(), noIndexPackages = new Set() } = {},
178
+ ) {
172
179
  const index = new Map();
173
180
  const contentTypes = new Set();
174
181
  const sections = new Set();
@@ -298,6 +305,7 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
298
305
  contentTypes,
299
306
  sections,
300
307
  packages,
308
+ noIndexPackages,
301
309
  refIndex,
302
310
  conflicts,
303
311
  };
@@ -347,6 +355,7 @@ export function wikiContext(
347
355
  sections: built.sections,
348
356
  contentTypes: built.contentTypes,
349
357
  packages: built.packages,
358
+ noIndexPackages: built.noIndexPackages,
350
359
  // The package a link written on this page defaults to when it names
351
360
  // none. Taken from the resolved configuration, the same source
352
361
  // the index's own addresses are built from, so a bare link cannot
@@ -279,9 +279,10 @@ function isPlainMap(value) {
279
279
  *
280
280
  * **Every target that resolves nowhere fails the build**, and is
281
281
  * classified into the vocabulary all three resolvers share — `unlabelled`,
282
- * `not-an-address`, `unknown-type`, `ambiguous`, `unresolved`. Failures are
283
- * collected in `ctx.errors`, each carrying the authored `link` and its
284
- * `occurrence` so a caller can report the line and column it sits on.
282
+ * `not-an-address`, `unknown-type`, `ambiguous`, `unresolved`,
283
+ * `no-content-index`. Failures are collected in `ctx.errors`, each carrying
284
+ * the authored `link` and its `occurrence` so a caller can report the line and
285
+ * column it sits on.
285
286
  *
286
287
  * There is deliberately no exception letting a hyphen-form address through while
287
288
  * any linkable package had no vendored manifest, since a real cross-package
@@ -312,9 +313,12 @@ function isPlainMap(value) {
312
313
  *
313
314
  * @param {string} body - The markdown body.
314
315
  * @param {object} ctx - `{ index, assets, collide, sections, contentTypes,
315
- * packages, foreign, type, errors, src, file }`.
316
+ * packages, noIndexPackages, foreign, type, errors, src, file }`.
316
317
  * `packages` is every package an address may name, without which the leading
317
- * package segment of a canonical address reads as an unknown type; `foreign`
318
+ * package segment of a canonical address reads as an unknown type;
319
+ * `noIndexPackages` is every package declared `contentIndex: false`, so a
320
+ * qualified address naming one fails with `no-content-index` rather than
321
+ * `not-an-address`; `foreign`
318
322
  * is the cross-package manifest index; `assets` is the address space an embed
319
323
  * resolves against. `src` is the page's display
320
324
  * path and `file` the source file a diagnostic should name — absent, `src`
@@ -402,7 +406,12 @@ export function resolveWebWikilinks(body, ctx) {
402
406
  // The canonical separator has to be resolved, not merely
403
407
  // recognised. `null` here means the target is not an address at all,
404
408
  // which is a defect: there is no other namespace to try.
405
- const read = readQualifier(target, ctx.contentTypes ?? new Set(), ctx.packages);
409
+ const read = readQualifier(
410
+ target,
411
+ ctx.contentTypes ?? new Set(),
412
+ ctx.packages,
413
+ ctx.noIndexPackages,
414
+ );
406
415
  const rawKey = target.toLowerCase();
407
416
  const hit =
408
417
  lookupRead(ctx.index, read, ctx.contentPackage) ??
@@ -478,6 +487,7 @@ export function resolveWebWikilinks(body, ctx) {
478
487
  // a key: a partial address has no single key to be non-null.
479
488
  : (read && !read.reason) || siteAddress ? "unresolved"
480
489
  : read?.reason === "unknown-type" ? "unknown-type"
490
+ : read?.reason === "no-content-index" ? "no-content-index"
481
491
  // Every link is an address, and this is not one. Distinct from
482
492
  // a dead address, because the fix is different: a name has to
483
493
  // become an address, not be corrected.
@@ -184,6 +184,9 @@ export function unlabelledLinkMessage(target) {
184
184
  * - `unresolved` — parses as an address, and nothing publishes it.
185
185
  * - `ambiguous` — more than one package publishes the short address.
186
186
  * - `unknown-anchor` — the address resolved, the `#section` it names did not.
187
+ * - `no-content-index` — the address names a package declared
188
+ * `contentIndex: false`, a Foundry dependency only, so no index was fetched
189
+ * for it to resolve against.
187
190
  *
188
191
  * @type {ReadonlySet<string>}
189
192
  */
@@ -197,6 +200,7 @@ export const LINK_FINDING_REASONS = Object.freeze(
197
200
  "unresolved",
198
201
  "ambiguous",
199
202
  "unknown-anchor",
203
+ "no-content-index",
200
204
  ]),
201
205
  );
202
206
 
@@ -312,6 +316,12 @@ export function linkFindingMessage({ reason, target, packages, anchor, type }) {
312
316
  );
313
317
  case "unresolved":
314
318
  return unresolvedAddressMessage(target);
319
+ case "no-content-index":
320
+ return (
321
+ `address [[${target}]] names a package declared \`contentIndex: false\` — ` +
322
+ `it is a Foundry dependency only, and no content index was fetched for it, ` +
323
+ `so nothing it publishes can be cited`
324
+ );
315
325
  default:
316
326
  throw new Error(
317
327
  `linkFindingMessage: "${reason}" is not one of ` +
@@ -203,13 +203,18 @@ export function resolveItemDocType(qualifier, types) {
203
203
  * @param {Set<string>} types - Every type the content tree contains.
204
204
  * @param {Set<string>} [packages] - Every package an address may name. Omitted
205
205
  * by callers that resolve within one package, where the form cannot occur.
206
+ * @param {Set<string>} [noIndexPackages] - Packages declared `contentIndex:
207
+ * false` — a Foundry dependency only, with no fetched index. A fully
208
+ * qualified target naming one is refused with `no-content-index` before its
209
+ * type is even considered, since there is no index to resolve it against.
206
210
  * @returns {{type: string, shortcode: string, itemDoc: boolean,
207
211
  * package?: string, system?: string, reason?: undefined}
208
- * | {reason: "unknown-type"} | null}
212
+ * | {reason: "unknown-type"|"no-content-index", package?: string} | null}
209
213
  * The resolved qualifier; a `reason` when the target is definitely qualified
210
- * but names no known type; or `null` when it is not an address at all.
214
+ * but names no known type or no fetched index; or `null` when it is not an
215
+ * address at all.
211
216
  */
212
- export function readQualifier(target, types, packages) {
217
+ export function readQualifier(target, types, packages, noIndexPackages) {
213
218
  // **Package, system and type are lowercase; the shortcode is not.** A
214
219
  // shortcode is case-sensitive and routinely mixed — `Clb`, `LtShoe`,
215
220
  // `HsTunic` — so it is written as the note declares it. The three segments
@@ -221,7 +226,7 @@ export function readQualifier(target, types, packages) {
221
226
  // (`[[Shock State]]`), and calling that a badly-cased address rather than
222
227
  // not an address would name the wrong mistake. Neither tree carries a
223
228
  // violation — 10,538 authored targets — so this pins a rule already kept.
224
- const read = readQualifierCased(target, types, packages);
229
+ const read = readQualifierCased(target, types, packages, noIndexPackages);
225
230
  if (read && !read.reason && qualifyingSegments(target).some((s) => /[A-Z]/.test(s))) {
226
231
  return { reason: "not-lowercase" };
227
232
  }
@@ -249,9 +254,10 @@ function qualifyingSegments(target) {
249
254
  * @param {string} target
250
255
  * @param {Set<string>} types
251
256
  * @param {Set<string>} [packages]
257
+ * @param {Set<string>} [noIndexPackages]
252
258
  * @returns {object|null}
253
259
  */
254
- function readQualifierCased(target, types, packages) {
260
+ function readQualifierCased(target, types, packages, noIndexPackages) {
255
261
  // The slash form is legacy and states neither package nor system, so it is
256
262
  // read first and separately. A slash is unconditionally a qualifier —
257
263
  // nothing else uses one — which is why an unknown type before it is
@@ -281,6 +287,10 @@ function readQualifierCased(target, types, packages) {
281
287
  // qualified.
282
288
  case 4: {
283
289
  const pkg = norm(parts[0]);
290
+ // Checked before the type: a package with no fetched index has no
291
+ // vocabulary to resolve the rest of the target against, and the
292
+ // fix is the config declaration, not the shortcode.
293
+ if (noIndexPackages?.has(pkg)) return { reason: "no-content-index", package: pkg };
284
294
  if (!packages?.has(pkg)) return null;
285
295
  const system = norm(parts[1]);
286
296
  if (!isSystemSegment(system)) return null;
@@ -359,11 +369,21 @@ export function anchorPageId(noteId, anchorSlug) {
359
369
  * @param {Map<string, object>} [opts.assets] - The files this package ships, by
360
370
  * canonical address. They resolve no link — an asset is not a document — and
361
371
  * answer only the art fields, which name a file and never a document.
372
+ * @param {Set<string>} [opts.noIndexPackages] - Packages declared
373
+ * `contentIndex: false` — a Foundry dependency only. A link naming one fails
374
+ * with `no-content-index` rather than resolving, ambiguously, as either a
375
+ * typo or an undeclared package.
362
376
  * @returns {{byShortcode: Map<string, object>, types: Set<string>}} `types` is
363
377
  * every type the tree actually contains, so a qualifier naming no real type
364
378
  * can be told apart from a missing target.
365
379
  */
366
- export function buildWikilinkIndex(docs, packageId, foreign, contentPackage, { assets } = {}) {
380
+ export function buildWikilinkIndex(
381
+ docs,
382
+ packageId,
383
+ foreign,
384
+ contentPackage,
385
+ { assets, noIndexPackages } = {},
386
+ ) {
367
387
  if (!packageId) {
368
388
  throw new Error(
369
389
  "buildWikilinkIndex: packageId is required — it is the first " +
@@ -454,6 +474,8 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage, { a
454
474
  foreign: foreignByKey,
455
475
  /** The files this package ships, by canonical address. */
456
476
  assets: assets ?? new Map(),
477
+ /** Packages declared `contentIndex: false`, a Foundry dependency only. */
478
+ noIndexPackages: noIndexPackages ?? new Set(),
457
479
  };
458
480
  }
459
481
 
@@ -656,7 +678,12 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
656
678
  if (target === "" && slug) {
657
679
  doc = { type, id, pack, docPack };
658
680
  } else {
659
- const qualified = readQualifier(target, index.types, index.packages);
681
+ const qualified = readQualifier(
682
+ target,
683
+ index.types,
684
+ index.packages,
685
+ index.noIndexPackages,
686
+ );
660
687
  qualifiedRead = qualified;
661
688
  // A target that does not parse as an address is a defect: there is
662
689
  // no second namespace left to fall through to.
package/manifest.mjs CHANGED
@@ -28,10 +28,12 @@
28
28
  *
29
29
  * - **Declared** — the `packageBuild.manifest` block, emitted unchanged, so a
30
30
  * key Foundry adds in a later version needs no release of this package.
31
- * - **Derived** — the identity, the description, the version, the release
32
- * addresses, the compatibility ranges and the pack list. Declaring one of
33
- * these is an error rather than an override: the authored copy would be
34
- * silently overwritten.
31
+ * `descriptionHtml` is the one exception, folded into the derived
32
+ * `description` below rather than surviving under its own name.
33
+ * - **Derived** the identity, the description (from `descriptionHtml`), the
34
+ * version, the release addresses, the compatibility ranges and the pack
35
+ * list. Declaring `description` directly is an error rather than an
36
+ * override: the authored copy would be silently overwritten.
35
37
  * - **Computed** — namespaced `flags` a repository works out for itself.
36
38
  *
37
39
  * **Nothing here invents an address.** The repository URL is read from
@@ -459,11 +461,13 @@ function withoutBuildKeys(entry) {
459
461
  * Three kinds of key end up in the result:
460
462
  *
461
463
  * - **Declared** — everything in `packageBuild.manifest`, emitted unchanged, so
462
- * a key Foundry adds later needs no release of this package.
464
+ * a key Foundry adds later needs no release of this package. The one
465
+ * exception is `descriptionHtml`, folded into the description below rather
466
+ * than surviving under its own name.
463
467
  * - **Derived** — the identity, the description, the release addresses, the
464
468
  * version, the Foundry and system compatibility ranges, and the pack list.
465
- * These are refused if also declared: an authored copy would be overwritten
466
- * and the two would disagree with nothing to say so.
469
+ * These are refused if also declared (`description` directly; `descriptionHtml`
470
+ * is how it is authored) and the two would disagree with nothing to say so.
467
471
  * - **Computed** — namespaced `flags` a repository works out for itself, merged
468
472
  * over any it declared.
469
473
  *
@@ -479,7 +483,9 @@ function withoutBuildKeys(entry) {
479
483
  * @returns {object} The manifest, ready to serialise.
480
484
  */
481
485
  export function buildManifest({ config, packageJson, artifact, flags }) {
482
- const declared = config.packageBuild?.manifest ?? {};
486
+ // `descriptionHtml` is the authored source of `description` — pulled out
487
+ // so it never survives the spread below under its own name.
488
+ const { descriptionHtml, ...declared } = config.packageBuild?.manifest ?? {};
483
489
  const repoUrl = normalizeRepoUrl(packageJson.repository);
484
490
 
485
491
  const derived = {
@@ -495,8 +501,8 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
495
501
  };
496
502
  // Own-property presence, not just value, decides whether a key survives
497
503
  // into `ordered` below — an explicit `undefined` would still occupy a slot
498
- // in it. Set only when `package.json` actually declares one.
499
- if (packageJson.description !== undefined) derived.description = packageJson.description;
504
+ // in it. Set only when the repository actually declares one.
505
+ if (descriptionHtml !== undefined) derived.description = descriptionHtml;
500
506
  if (config.compatibility) derived.compatibility = config.compatibility;
501
507
 
502
508
  // `requiresSystem` is the gate half of the declare/require split. It
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.1.0",
3
+ "version": "22.2.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
package/release.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  /**
15
15
  * The release archive — the two files a Foundry package's GitHub Release
16
- * carries.
16
+ * carries, plus the optional siblings a repository opts into.
17
17
  *
18
18
  * Foundry installs a package by fetching the `download` URL its manifest
19
19
  * advertises, so a release publishes `<artifact>.zip`, the whole staged tree,
@@ -21,7 +21,10 @@
21
21
  * re-fetches to notice a new version. A package that ships content publishes a
22
22
  * third: the content index other packages resolve its addresses through,
23
23
  * named by the `flags.metadataUrl` the manifest advertises. Every name is fixed
24
- * by what the manifest says, not chosen here — see `manifest.mjs`.
24
+ * by what the manifest says, not chosen here — see `manifest.mjs`. A package
25
+ * that publishes a DataModel schema (`packageBuild.schema`, staged through
26
+ * `packageBuild.assets`) gets a fourth: `schema.json`, published whenever the
27
+ * stage carries one and silently skipped otherwise.
25
28
  *
26
29
  * Kept apart from `stage.mjs` because this is the only part of assembling a
27
30
  * package that needs a dependency. A repository that never cuts a release from
@@ -40,6 +43,8 @@ import path from "node:path";
40
43
  // release job came to fail before a single byte was written.
41
44
  import { ZipArchive } from "archiver";
42
45
 
46
+ import { SCHEMA_ARTIFACT_FILE } from "./engine/foreign-catalog.mjs";
47
+
43
48
  /**
44
49
  * Zip the staged tree and place the manifest beside the archive.
45
50
  *
@@ -60,14 +65,14 @@ import { ZipArchive } from "archiver";
60
65
  * @param {boolean} [opts.pdf] - Whether to build the book that ships beside the
61
66
  * archive. `true` by default; `false` skips the build and reports the skip.
62
67
  * @returns {Promise<{zip: string, manifest: string, metadata?: string,
63
- * pdf?: string, pdfFindings: object[], pdfSkipped: string|null,
68
+ * schema?: string, pdf?: string, pdfFindings: object[], pdfSkipped: string|null,
64
69
  * bytes: number, version: string}>} The paths written, what the book build
65
70
  * found, the archive's size, and the version the manifest declares.
66
- * `metadata` is absent when the manifest advertises no content index, and
67
- * `pdf` is absent when no book was written. `pdfSkipped` is `null` when a
68
- * book was built, and otherwise the reason none was — itself `null` when the
69
- * book builder could not be loaded, which is reported through
70
- * `pdfFindings`.
71
+ * `metadata` is absent when the manifest advertises no content index,
72
+ * `schema` is absent when the stage carries no `schema.json`, and `pdf` is
73
+ * absent when no book was written. `pdfSkipped` is `null` when a book was
74
+ * built, and otherwise the reason none was itself `null` when the book
75
+ * builder could not be loaded, which is reported through `pdfFindings`.
71
76
  * @throws {Error} When the stage has no manifest — there is nothing to release,
72
77
  * and an archive without one installs as nothing.
73
78
  */
@@ -119,6 +124,7 @@ export async function packRelease({
119
124
  await fsp.copyFile(stagedManifest, path.join(out, manifestName));
120
125
 
121
126
  const metadata = await publishMetadataIndex({ manifest, stage, out, metadataDir });
127
+ const schema = await publishSchemaAsset({ stage, out });
122
128
 
123
129
  // Last, and never fatal: the archive and the manifest are the release, and
124
130
  // a book that failed to set is a reported problem rather than a reason to
@@ -132,6 +138,7 @@ export async function packRelease({
132
138
  zip: zipPath,
133
139
  manifest: path.join(out, manifestName),
134
140
  ...(metadata ? { metadata } : {}),
141
+ ...(schema ? { schema } : {}),
135
142
  ...(book.pdf ? { pdf: book.pdf } : {}),
136
143
  pdfFindings: book.findings,
137
144
  pdfSkipped: book.pdf ? null : book.reason,
@@ -226,3 +233,29 @@ async function publishMetadataIndex({ manifest, stage, out, metadataDir }) {
226
233
  await fsp.copyFile(found, dest);
227
234
  return dest;
228
235
  }
236
+
237
+ /**
238
+ * Place the published DataModel schema beside the archive, when the stage
239
+ * carries one.
240
+ *
241
+ * **The stage is the one place this looks.** `package-build schema` writes
242
+ * `build/schema.json`, and a repository that wants it released names it in
243
+ * `packageBuild.assets` (`from: build/schema.json`) the way every other
244
+ * staged file is declared — so a schema at the stage root is a repository
245
+ * that opted in, and its absence is a repository that has not, which is
246
+ * exactly as releasable as one that never adopted the artifact at all.
247
+ *
248
+ * @param {object} opts
249
+ * @param {string} opts.stage - The staged tree.
250
+ * @param {string} opts.out - Where release assets are written.
251
+ * @returns {Promise<string|undefined>} The published path, or nothing when
252
+ * the stage carries no `schema.json`.
253
+ */
254
+ async function publishSchemaAsset({ stage, out }) {
255
+ const src = path.join(stage, SCHEMA_ARTIFACT_FILE);
256
+ if (!fs.existsSync(src)) return undefined;
257
+
258
+ const dest = path.join(out, SCHEMA_ARTIFACT_FILE);
259
+ await fsp.copyFile(src, dest);
260
+ return dest;
261
+ }
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Reject a configured value, naming the key it was written under.
3
+ *
4
+ * The dotted path rides on the error as `field` as well as appearing in the
5
+ * message, so {@link loadPackageBuildConfig} — the half that knows which file
6
+ * was read — can resolve it to a line and column. This half stays pure.
7
+ *
8
+ * Exported so a sibling module composing a configuration value this module
9
+ * does not itself validate — {@link module:engine/site-config}'s
10
+ * `hugoConfig`, checking `site.assets` — reports through the one helper
11
+ * rather than a second copy.
12
+ *
13
+ * @param {string} where - Dotted path of the offending key.
14
+ * @param {string} problem - What is wrong with it.
15
+ * @returns {never}
16
+ */
17
+ export function fail(where: string, problem: string): never;
1
18
  /**
2
19
  * Where a declared `assetTransform` is loaded from.
3
20
  *
@@ -505,6 +505,17 @@ export type RelationshipSpec = {
505
505
  * `_stats.systemVersion` is stamped from.
506
506
  */
507
507
  compatibility?: CompatibilitySpec | undefined;
508
+ /**
509
+ * Whether `deps fetch` fetches this
510
+ * dependency's content index. Default
511
+ * `true`. `false` declares the dependency
512
+ * for the Foundry manifest only — nothing
513
+ * this tree cites by wikilink — and refuses
514
+ * `itemCatalog: true` on the same entry,
515
+ * since a catalogue is fetched from the same
516
+ * index.
517
+ */
518
+ contentIndex?: boolean | undefined;
508
519
  };
509
520
  /**
510
521
  * How a generated documentation page is framed in the repository publishing it.
@@ -144,8 +144,9 @@ export function auditHomepageLinks(index: ReturnType<typeof buildLinkIndex>): Ar
144
144
  * which addresses a foreign manifest answered. Each `deadAddresses` entry
145
145
  * carries a `reason` from {@link LINK_FINDING_REASONS} —
146
146
  * `"not-an-address"`, `"unknown-type"`, `"ambiguous"` (with the claiming
147
- * `packages`), or `"unresolved"` — and every one of them is an **error**:
148
- * the three resolvers agree on severity for every class.
147
+ * `packages`), `"no-content-index"`, or `"unresolved"` — and every one of
148
+ * them is an **error**: the three resolvers agree on severity for every
149
+ * class.
149
150
  */
150
151
  export function auditLinks(index: ReturnType<typeof buildLinkIndex>): {
151
152
  deadAnchors: object[];
@@ -9,6 +9,11 @@
9
9
  * and needing no items is the mirror of it. Gating the index on the catalogue
10
10
  * flag would serve neither.
11
11
  *
12
+ * **Excludes a relationship declaring `contentIndex: false`.** That opts a
13
+ * dependency out of both edges at once: it is a Foundry dependency only, cited
14
+ * by neither a wikilink nor an item reference, so there is nothing here for
15
+ * `deps fetch` to fill and no cache this build will ever read.
16
+ *
12
17
  * The declaration is the one already in the emitted `system.json` /
13
18
  * `module.json`, so it cannot drift from what Foundry itself installs, and
14
19
  * there is no new configuration key to keep in step. Each entry carries the
@@ -25,6 +30,23 @@ export function metadataRelationships(config: object): Array<{
25
30
  kind: string;
26
31
  verified: string | undefined;
27
32
  }>;
33
+ /**
34
+ * Every package a relationship declares `contentIndex: false` on, keyed by
35
+ * the content package name a link into it would use.
36
+ *
37
+ * A separate set from {@link metadataRelationships}, which answers "what does
38
+ * `deps fetch` fill" — this answers "what does the link resolver recognise as
39
+ * a package with no fetched index", which a wikilink checker or pack compiler
40
+ * needs to tell that case apart from a package nobody declared at all.
41
+ *
42
+ * Walked across every relationship kind, not only the citable ones: the
43
+ * config validation refuses the flag nowhere by kind, so a resolver reading it
44
+ * back should not assume one either.
45
+ *
46
+ * @param {object} config - The resolved build configuration.
47
+ * @returns {ReadonlySet<string>} The content package names.
48
+ */
49
+ export function noContentIndexPackages(config: object): ReadonlySet<string>;
28
50
  /**
29
51
  * The cache directory for one dependency's index at one version.
30
52
  *
@@ -50,6 +50,18 @@ export function exclusiveTagGroups(type: string, groups?: object): {
50
50
  * @returns {boolean} Whether the note carries it.
51
51
  */
52
52
  export function hasTag(fm: object | null | undefined, tag: string): boolean;
53
+ /**
54
+ * Whether a note carries any `tags:` at all, however authored.
55
+ *
56
+ * The one question the site build asks of tags in aggregate — whether the
57
+ * tree publishes taxonomy pages — rather than about a particular tag. Reads
58
+ * `tags` and `tag` exactly as {@link hasTag} does, and treats an empty list
59
+ * or a blank string as carrying none.
60
+ *
61
+ * @param {object|null|undefined} fm - Parsed frontmatter.
62
+ * @returns {boolean} Whether the note carries at least one tag.
63
+ */
64
+ export function hasAnyTag(fm: object | null | undefined): boolean;
53
65
  /**
54
66
  * Whether a note is tagged as an unfinished **draft**.
55
67
  *