@heroiclands/package-build 0.6.0 → 3.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 (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +95 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -34
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,218 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Declared frontmatter fields — what a note may write, said out loud.
16
+ *
17
+ * **The declaration is the builder, not a description of one.** A builder used
18
+ * to be an opaque function: the mapping from a note's `sohl:` frontmatter to
19
+ * the emitted `system` block existed only inside its body, so nothing could
20
+ * read it — not a documentation generator, not a validator, not a person
21
+ * (#22). The obvious repair, a table written *beside* the function, buys very
22
+ * little: two statements of one rule drift, and nothing notices.
23
+ *
24
+ * So the table is the only statement. {@link buildFromFields} turns a field
25
+ * list into the builder, which means a field that is not declared is not
26
+ * emitted, and a field whose declaration changes changes the output. There is
27
+ * no second place for the truth to live.
28
+ *
29
+ * **A field carries its coercion, not just its name.** `weight` is not merely
30
+ * "a number" — it is *this* reading of a raw YAML value, with *this* default
31
+ * when absent. Pairing the two ({@link NUMBER} and friends are `{shape, read}`
32
+ * pairs, spread into a field) means the documented shape and the executed
33
+ * coercion cannot disagree either: they are one object.
34
+ *
35
+ * **Not everything in a `system` block is authored.** Constants (`quantity: 1`)
36
+ * and derived values (a projectile's `numDice`, which follows from its die)
37
+ * are declared with a `value` instead of a frontmatter `name`, so the emitted
38
+ * block stays complete while the author-facing reference — anything built on
39
+ * {@link authoredFields} — lists only what an author can actually write.
40
+ *
41
+ * Plain ESM, no configuration, no filesystem — a leaf, importable by anything.
42
+ *
43
+ * @module
44
+ */
45
+
46
+ import { sohlField } from "./frontmatter.mjs";
47
+
48
+ /**
49
+ * @typedef {object} FieldSpec
50
+ * @property {string} to - Dotted path in the emitted `system` block.
51
+ * @property {string} [name] - Frontmatter key under `sohl:`, dotted for a
52
+ * nested one (`impact.die`). Absent means the value is not authored — see
53
+ * `value`.
54
+ * @property {string} [shape] - Human-readable shape, for documentation. Comes
55
+ * paired with `read` from one of the coercion constants below.
56
+ * @property {(raw: any, ctx: {fm: object, field: FieldSpec}) => any} [read] -
57
+ * How the raw frontmatter value becomes the emitted one. Identity if absent.
58
+ * @property {any} [default] - Emitted when the note does not carry the field.
59
+ * @property {boolean} [required] - Whether a note must carry it. A required
60
+ * field's `read` is expected to throw when it is missing.
61
+ * @property {"string"|"number"|"boolean"|"list"|"map"} [kind] - The value's
62
+ * shape, for the frontmatter linter (#19). Distinct from `shape`, which is
63
+ * prose for a reader, and from `read`, which is what the compiler does: a
64
+ * field may declare `kind` without changing a byte of what it emits, and
65
+ * several do — `weight` is coerced leniently but is still a number, and
66
+ * `weight: heavy` is an authoring mistake worth reporting where it was made.
67
+ * Absent means the lint makes no claim about the value.
68
+ * @property {string} [ref] - The content type a value addresses by shortcode,
69
+ * for the linter's dead-reference check. Only for references to a **note**:
70
+ * `bodyLocationCode` names a part inside a being's own body structure, not a
71
+ * note, so it declares none.
72
+ * @property {any|((fm: object) => any)} [value] - For a field with no `name`:
73
+ * the constant, or a function deriving it from the frontmatter.
74
+ * @property {string} describe - One line, for the author-facing reference.
75
+ */
76
+
77
+ /* --------------------------------------------------------------------- */
78
+ /* Coercions — a shape and its reading, as one object */
79
+ /* --------------------------------------------------------------------- */
80
+
81
+ /** Whatever the author wrote, unconverted. */
82
+ export const AS_AUTHORED = Object.freeze({ shape: "as authored" });
83
+
84
+ /** Coerced with `String()`. */
85
+ export const STRING = Object.freeze({
86
+ shape: "string",
87
+ kind: "string",
88
+ read: (raw) => String(raw),
89
+ });
90
+
91
+ /** Coerced with `Number()`, with a non-numeric or absent value reading `0`. */
92
+ export const NUMBER = Object.freeze({
93
+ shape: "number",
94
+ kind: "number",
95
+ read: (raw) => Number(raw) || 0,
96
+ });
97
+
98
+ /** Coerced with `Boolean()`. */
99
+ export const BOOLEAN = Object.freeze({
100
+ shape: "boolean",
101
+ kind: "boolean",
102
+ read: (raw) => Boolean(raw),
103
+ });
104
+
105
+ /**
106
+ * A number whose *absence* is meaningful: unset or blank ships `null`, and any
107
+ * other value goes through `Number()` unguarded (so a non-numeric one is
108
+ * `NaN`, not a silent `0` — an authoring mistake worth seeing).
109
+ */
110
+ export const NULLABLE_NUMBER = Object.freeze({
111
+ shape: "number or unset",
112
+ kind: "number",
113
+ read: (raw) => (raw == null || raw === "" ? null : Number(raw)),
114
+ });
115
+
116
+ /**
117
+ * A number whose absence is meaningful, but whose *value* is guarded: unset
118
+ * ships `null`, anything else reads as a number defaulting to `0`.
119
+ */
120
+ export const NULLABLE_COUNT = Object.freeze({
121
+ shape: "number or unset",
122
+ kind: "number",
123
+ read: (raw) => (raw == null ? null : Number(raw) || 0),
124
+ });
125
+
126
+ /** Anything falsy — including a cleared `""` — ships `null`. */
127
+ export const BLANK_IS_NULL = Object.freeze({
128
+ shape: "as authored, blank is unset",
129
+ read: (raw) => raw || null,
130
+ });
131
+
132
+ /** Anything falsy — including a cleared `""` — falls back to the default. */
133
+ export const BLANK_IS_DEFAULT = Object.freeze({
134
+ shape: "as authored, blank is the default",
135
+ read: (raw, { field }) => raw || field.default,
136
+ });
137
+
138
+ /* --------------------------------------------------------------------- */
139
+ /* Applying a declaration */
140
+ /* --------------------------------------------------------------------- */
141
+
142
+ /**
143
+ * Write `value` at a dotted path, creating the intermediate objects.
144
+ *
145
+ * Insertion order is the emitted JSON's key order, so a declaration's order is
146
+ * the compiled document's order — which is what lets a field list replace a
147
+ * hand-written object literal without changing a single byte of output.
148
+ *
149
+ * @param {object} target - The object to write into (mutated).
150
+ * @param {string} dotted - Path, e.g. `"locations.flexible"`.
151
+ * @param {any} value - The value to set.
152
+ * @returns {object} `target`, for chaining.
153
+ */
154
+ export function setPath(target, dotted, value) {
155
+ const parts = dotted.split(".");
156
+ const leaf = parts.pop();
157
+ let cursor = target;
158
+ for (const part of parts) {
159
+ if (
160
+ cursor[part] == null ||
161
+ typeof cursor[part] !== "object" ||
162
+ Array.isArray(cursor[part])
163
+ ) {
164
+ cursor[part] = {};
165
+ }
166
+ cursor = cursor[part];
167
+ }
168
+ cursor[leaf] = value;
169
+ return target;
170
+ }
171
+
172
+ /**
173
+ * Read one declared field out of a note's frontmatter.
174
+ *
175
+ * @param {FieldSpec} field - The declaration.
176
+ * @param {object} fm - The note's frontmatter.
177
+ * @returns {any} The value to emit.
178
+ */
179
+ export function readField(field, fm) {
180
+ if (field.name === undefined) {
181
+ return typeof field.value === "function" ?
182
+ field.value(fm)
183
+ : field.value;
184
+ }
185
+ const raw = sohlField(fm, field.name, field.default);
186
+ return field.read ? field.read(raw, { fm, field }) : raw;
187
+ }
188
+
189
+ /**
190
+ * Turn a field declaration into the builder it declares.
191
+ *
192
+ * @param {readonly FieldSpec[]} fields - The declaration, in emission order.
193
+ * @returns {(fm: object) => object} A `system`-block builder.
194
+ */
195
+ export function buildFromFields(fields) {
196
+ return function buildDeclaredSystem(fm) {
197
+ const out = {};
198
+ for (const field of fields) {
199
+ setPath(out, field.to, readField(field, fm));
200
+ }
201
+ return out;
202
+ };
203
+ }
204
+
205
+ /**
206
+ * The fields of a declaration an author actually writes.
207
+ *
208
+ * Constants and derived values are part of the emitted document but not part of
209
+ * the vocabulary, so every author-facing surface — the reference generator, a
210
+ * frontmatter linter, an unknown-key check — wants this list rather than the
211
+ * whole declaration.
212
+ *
213
+ * @param {readonly FieldSpec[]} fields - The declaration.
214
+ * @returns {FieldSpec[]} Only the fields with a frontmatter `name`.
215
+ */
216
+ export function authoredFields(fields) {
217
+ return fields.filter((field) => field.name !== undefined);
218
+ }
@@ -0,0 +1,450 @@
1
+ /**
2
+ * @file The item catalogue of a package this repository depends on but does not
3
+ * contain.
4
+ *
5
+ * A consuming repository may author **beings** without holding the items they
6
+ * are assembled from. `sohl-thalorna` is the case: its being notes address
7
+ * embedded items by `(type, shortcode)` — `attribute:str`, `skill:awar` — and
8
+ * almost every one of those belongs to the `sohl` package. The actors pass
9
+ * resolves against Item pack output, so with no local items there is nothing to
10
+ * resolve against and every embedded item fails.
11
+ *
12
+ * The dependency is already declared, with a manifest URL and a version range:
13
+ *
14
+ * ```yaml
15
+ * relationships:
16
+ * systems:
17
+ * - id: sohl
18
+ * manifest: https://…/releases/latest/download/system.json
19
+ * compatibility: { minimum: "0.8.2", verified: "0.8.2" }
20
+ * itemCatalog: true
21
+ * ```
22
+ *
23
+ * `itemCatalog: true` opts that relationship in. This module turns it into
24
+ * directories of item JSON that the actors pass reads exactly as it reads a
25
+ * local pack's output — the resolution logic needs no knowledge of where an
26
+ * item came from.
27
+ *
28
+ * **The network is never touched by a compile.** Fetching is its own command
29
+ * (`content-build deps fetch`), and a compile whose cache is cold fails saying
30
+ * so. A build that silently downloads is not reproducible, fails strangely
31
+ * offline, and hides a version change behind a passing run.
32
+ *
33
+ * **Nor does the catalogue have to come from a release.** `deps fetch --from
34
+ * <path>` takes a locally built artifact — the `.zip` a package build produces,
35
+ * or the staged directory it produces it from — and fills the same cache with
36
+ * it. Cutting a release is a publishing decision; needing one in order to test a
37
+ * consumer against unreleased work would make it a build step too. This is also
38
+ * how a consumer is tested against a dependency change before either ships.
39
+ */
40
+
41
+ import fs from "node:fs";
42
+ import os from "node:os";
43
+ import path from "node:path";
44
+
45
+ import { unzipSync } from "fflate";
46
+ import { extractPack } from "@foundryvtt/foundryvtt-cli";
47
+
48
+ import log from "loglevel";
49
+
50
+ /** Written once a fetch completes, so a half-finished cache is never used. */
51
+ const STAMP = ".complete";
52
+
53
+ /**
54
+ * Every declared relationship that opted into supplying an item catalogue.
55
+ *
56
+ * @param {object} config - The resolved build configuration.
57
+ * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
58
+ * The opted-in relationships, in declaration order.
59
+ */
60
+ export function itemCatalogRelationships(config) {
61
+ const out = [];
62
+ for (const [kind, entries] of Object.entries(config.relationships ?? {})) {
63
+ for (const rel of entries ?? []) {
64
+ if (rel.itemCatalog) {
65
+ out.push({
66
+ id: rel.id,
67
+ manifest: rel.manifest,
68
+ kind,
69
+ verified: rel.compatibility?.verified,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * The cache directory for one dependency at one version.
79
+ *
80
+ * Keyed by version so that changing the pinned version is a different cache
81
+ * rather than a silent overwrite, and so a second build costs nothing.
82
+ *
83
+ * @param {object} config - The resolved build configuration.
84
+ * @param {string} id - The dependency's package id.
85
+ * @param {string} version - Its resolved version.
86
+ * @returns {string} The directory.
87
+ */
88
+ export function catalogDir(config, id, version) {
89
+ return path.join(config.paths.foreignCache, `${id}@${version}`);
90
+ }
91
+
92
+ /**
93
+ * The directory holding extracted item JSON for one cached dependency.
94
+ *
95
+ * @param {string} dir - The dependency's cache directory.
96
+ * @returns {string} Its items directory.
97
+ */
98
+ const itemsDir = (dir) => path.join(dir, "items");
99
+
100
+ /**
101
+ * Whether a dependency's cache is present and complete.
102
+ *
103
+ * @param {string} dir - The dependency's cache directory.
104
+ * @returns {boolean} True when it was fetched to completion.
105
+ */
106
+ const isComplete = (dir) => fs.existsSync(path.join(dir, STAMP));
107
+
108
+ /**
109
+ * Read a dependency's manifest.
110
+ *
111
+ * @param {string} url - The manifest URL.
112
+ * @returns {Promise<object>} The parsed manifest.
113
+ */
114
+ async function fetchManifest(url) {
115
+ const res = await fetch(url, { redirect: "follow" });
116
+ if (!res.ok) {
117
+ throw new Error(
118
+ `could not read the manifest at ${url}: HTTP ${res.status} ${res.statusText}`,
119
+ );
120
+ }
121
+ return await res.json();
122
+ }
123
+
124
+ /**
125
+ * Write an unzipped archive's entries under `dest`.
126
+ *
127
+ * @param {Record<string, Uint8Array>} files - The archive's entries.
128
+ * @param {string} dest - Where to write them.
129
+ * @returns {void}
130
+ */
131
+ export function writeZipEntries(files, dest) {
132
+ for (const [name, bytes] of Object.entries(files)) {
133
+ // A zip entry is a path; a directory entry has no bytes.
134
+ if (name.endsWith("/") || bytes.length === 0) continue;
135
+ const full = path.join(dest, name);
136
+ // A zip may name entries outside the destination; refuse those rather
137
+ // than write wherever the archive says.
138
+ const rel = path.relative(dest, full);
139
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
140
+ throw new Error(`archive entry escapes the destination: ${name}`);
141
+ }
142
+ fs.mkdirSync(path.dirname(full), { recursive: true });
143
+ fs.writeFileSync(full, bytes);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Extract every Item pack a manifest declares, and stamp the cache complete.
149
+ *
150
+ * Shared by both routes, so a catalogue built from a local artifact is byte-for
151
+ * -byte the shape a compile expects from a released one.
152
+ *
153
+ * @param {string} id - The dependency's package id.
154
+ * @param {string} version - Its resolved version.
155
+ * @param {object} manifest - Its manifest.
156
+ * @param {string} root - The unpacked package root.
157
+ * @param {string} dir - The dependency's cache directory.
158
+ * @returns {Promise<void>}
159
+ */
160
+ async function extractItemPacks(id, version, manifest, root, dir) {
161
+ const itemPacks = (manifest.packs ?? []).filter(
162
+ (pack) => pack.type === "Item",
163
+ );
164
+ if (!itemPacks.length) {
165
+ throw new Error(
166
+ `${id}@${version}: its manifest declares no Item packs, so it ` +
167
+ `cannot supply an item catalogue`,
168
+ );
169
+ }
170
+ for (const pack of itemPacks) {
171
+ const src = resolvePackPath(root, pack.path);
172
+ if (!src) {
173
+ throw new Error(
174
+ `${id}@${version}: pack "${pack.name}" is declared at ` +
175
+ `${pack.path}, which the package does not contain`,
176
+ );
177
+ }
178
+ const out = path.join(dir, "items", pack.name);
179
+ fs.mkdirSync(out, { recursive: true });
180
+ await extractPack(src, out, { log: false });
181
+ log.info(`${id}@${version}: extracted pack "${pack.name}"`);
182
+ }
183
+ // Last, so a fetch that died partway is never mistaken for a complete one.
184
+ fs.writeFileSync(path.join(dir, STAMP), `${version}\n`);
185
+ }
186
+
187
+ /**
188
+ * Download a package archive and unzip it into `dest`.
189
+ *
190
+ * @param {string} url - The archive URL, from the manifest's `download`.
191
+ * @param {string} dest - Where to write the archive's contents.
192
+ * @returns {Promise<void>}
193
+ */
194
+ async function downloadAndUnzip(url, dest) {
195
+ const res = await fetch(url, { redirect: "follow" });
196
+ if (!res.ok) {
197
+ throw new Error(
198
+ `could not download ${url}: HTTP ${res.status} ${res.statusText}`,
199
+ );
200
+ }
201
+ writeZipEntries(unzipSync(new Uint8Array(await res.arrayBuffer())), dest);
202
+ }
203
+
204
+ /**
205
+ * The manifest URL to actually read, pinned to the declared version.
206
+ *
207
+ * A consumer writes `releases/latest/download/system.json`, which is the right
208
+ * thing to publish and the wrong thing to build against: the artifact behind it
209
+ * changes when somebody else cuts a release, so a build names no particular
210
+ * dependency and "thalorna 0.1.0" stops being reproducible. The declared
211
+ * `compatibility.verified` is the version this repository was actually built
212
+ * against, so that is the one to fetch.
213
+ *
214
+ * GitHub's release URLs are rewritable — `releases/latest/download/X` is
215
+ * `releases/download/v<version>/X`. Where the URL is not that shape there is
216
+ * nothing to rewrite, so the declared URL is read and its version checked
217
+ * instead: floating silently is the one outcome not on offer.
218
+ *
219
+ * @param {string} url - The declared manifest URL.
220
+ * @param {string|undefined} verified - The declared verified version.
221
+ * @returns {{url: string, pinned: boolean}} The URL to read.
222
+ */
223
+ export function pinnedManifestUrl(url, verified) {
224
+ if (!verified) return { url, pinned: false };
225
+ const marker = "/releases/latest/download/";
226
+ const at = url.indexOf(marker);
227
+ if (at === -1) return { url, pinned: false };
228
+ const tag = verified.startsWith("v") ? verified : `v${verified}`;
229
+ return {
230
+ url:
231
+ url.slice(0, at) +
232
+ `/releases/download/${tag}/` +
233
+ url.slice(at + marker.length),
234
+ pinned: true,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Fetch one dependency and extract its Item packs.
240
+ *
241
+ * Idempotent: a complete cache for the resolved version is left alone.
242
+ *
243
+ * @param {object} config - The resolved build configuration.
244
+ * @param {{id: string, manifest: string}} rel - The declared relationship.
245
+ * @returns {Promise<string>} The dependency's cache directory.
246
+ */
247
+ export async function fetchCatalog(config, rel) {
248
+ const { url, pinned } = pinnedManifestUrl(rel.manifest, rel.verified);
249
+ const manifest = await fetchManifest(url);
250
+ const version = manifest.version;
251
+ if (!version) {
252
+ throw new Error(`${rel.id}: its manifest declares no \`version\``);
253
+ }
254
+ if (!pinned && rel.verified && version !== rel.verified) {
255
+ throw new Error(
256
+ `${rel.id}: declares \`compatibility.verified: ${rel.verified}\` but ` +
257
+ `${url} offers ${version}. Building against a moving target is ` +
258
+ `not reproducible — update \`verified\`, or point \`manifest\` ` +
259
+ `at a pinned release.`,
260
+ );
261
+ }
262
+ if (!rel.verified) {
263
+ log.warn(
264
+ `${rel.id}: no \`compatibility.verified\`, so its catalogue floats ` +
265
+ `with whatever ${url} currently serves`,
266
+ );
267
+ }
268
+ const dir = catalogDir(config, rel.id, version);
269
+ if (isComplete(dir)) {
270
+ log.info(`${rel.id}@${version}: already cached`);
271
+ return dir;
272
+ }
273
+
274
+ const download = manifest.download;
275
+ if (!download) {
276
+ throw new Error(
277
+ `${rel.id}@${version}: its manifest declares no \`download\``,
278
+ );
279
+ }
280
+
281
+ // Rebuild from empty: a previous run may have died partway, and a stale
282
+ // half-tree is worse than no tree.
283
+ fs.rmSync(dir, { recursive: true, force: true });
284
+ const raw = path.join(dir, "package");
285
+ fs.mkdirSync(raw, { recursive: true });
286
+
287
+ log.info(`${rel.id}@${version}: downloading ${download}`);
288
+ await downloadAndUnzip(download, raw);
289
+
290
+ await extractItemPacks(rel.id, version, manifest, raw, dir);
291
+ return dir;
292
+ }
293
+
294
+ /**
295
+ * Locate a declared pack inside an unpacked archive.
296
+ *
297
+ * Foundry archives are inconsistent about whether they nest their contents
298
+ * under a top-level directory, so try the path as given and then one level in.
299
+ *
300
+ * @param {string} root - The unpacked archive root.
301
+ * @param {string} packPath - The manifest's declared pack path.
302
+ * @returns {string|null} The directory, or null when absent.
303
+ */
304
+ function resolvePackPath(root, packPath) {
305
+ const direct = path.join(root, packPath);
306
+ if (fs.existsSync(direct)) return direct;
307
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
308
+ if (!entry.isDirectory()) continue;
309
+ const nested = path.join(root, entry.name, packPath);
310
+ if (fs.existsSync(nested)) return nested;
311
+ }
312
+ return null;
313
+ }
314
+
315
+ /**
316
+ * The manifest of a package sitting on disk, unpacked or zipped.
317
+ *
318
+ * @param {string} root - The unpacked package root.
319
+ * @returns {object|null} Its parsed manifest, or null when it holds none.
320
+ */
321
+ function readLocalManifest(root) {
322
+ for (const name of ["system.json", "module.json"]) {
323
+ const direct = path.join(root, name);
324
+ if (fs.existsSync(direct)) {
325
+ return JSON.parse(fs.readFileSync(direct, "utf8"));
326
+ }
327
+ }
328
+ // A zip commonly nests everything under one directory.
329
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
330
+ if (!entry.isDirectory()) continue;
331
+ const nested = readLocalManifest(path.join(root, entry.name));
332
+ if (nested) return nested;
333
+ }
334
+ return null;
335
+ }
336
+
337
+ /**
338
+ * Fill the cache from a locally built artifact rather than a release.
339
+ *
340
+ * `source` is either the `.zip` a package build emits or the directory it was
341
+ * built from. Either way the manifest inside it names the version, so the cache
342
+ * stays version-keyed and a compile cannot tell the difference — which is the
343
+ * point: a consumer can be built against a dependency that has not shipped.
344
+ *
345
+ * @param {object} config - The resolved build configuration.
346
+ * @param {{id: string}} rel - The declared relationship.
347
+ * @param {string} source - Path to the artifact or its directory.
348
+ * @returns {Promise<string>} The dependency's cache directory.
349
+ */
350
+ export async function fetchCatalogFromPath(config, rel, source) {
351
+ if (!fs.existsSync(source)) {
352
+ throw new Error(`${rel.id}: nothing at ${source}`);
353
+ }
354
+
355
+ const staging = fs.mkdtempSync(
356
+ path.join(os.tmpdir(), `content-build-${rel.id}-`),
357
+ );
358
+ try {
359
+ let root;
360
+ if (fs.statSync(source).isDirectory()) {
361
+ root = source;
362
+ } else {
363
+ const bytes = new Uint8Array(fs.readFileSync(source));
364
+ writeZipEntries(unzipSync(bytes), staging);
365
+ root = staging;
366
+ }
367
+
368
+ const manifest = readLocalManifest(root);
369
+ if (!manifest) {
370
+ throw new Error(
371
+ `${rel.id}: ${source} holds no system.json or module.json, so ` +
372
+ `its version and packs cannot be read`,
373
+ );
374
+ }
375
+ if (manifest.id && manifest.id !== rel.id) {
376
+ throw new Error(
377
+ `${rel.id}: ${source} is package "${manifest.id}", not "${rel.id}"`,
378
+ );
379
+ }
380
+ const version = manifest.version;
381
+ if (!version) {
382
+ throw new Error(`${rel.id}: ${source} declares no \`version\``);
383
+ }
384
+
385
+ const dir = catalogDir(config, rel.id, version);
386
+ fs.rmSync(dir, { recursive: true, force: true });
387
+ await extractItemPacks(rel.id, version, manifest, root, dir);
388
+ log.info(`${rel.id}@${version}: cached from ${source}`);
389
+ return dir;
390
+ } finally {
391
+ fs.rmSync(staging, { recursive: true, force: true });
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Fetch every opted-in dependency. The `deps fetch` command.
397
+ *
398
+ * @param {object} config - The resolved build configuration.
399
+ * @returns {Promise<number>} How many dependencies were fetched.
400
+ */
401
+ export async function fetchAllCatalogs(config) {
402
+ const rels = itemCatalogRelationships(config);
403
+ if (!rels.length) {
404
+ log.info(
405
+ "No relationship declares `itemCatalog: true`; nothing to fetch.",
406
+ );
407
+ return 0;
408
+ }
409
+ for (const rel of rels) await fetchCatalog(config, rel);
410
+ return rels.length;
411
+ }
412
+
413
+ /**
414
+ * The extracted item directories the actors pass should resolve against, on
415
+ * top of this repository's own.
416
+ *
417
+ * Reads the cache only. A cold cache is an error naming the command that fills
418
+ * it, rather than a download nobody asked for.
419
+ *
420
+ * @param {object} config - The resolved build configuration.
421
+ * @returns {string[]} Every cached dependency's item directories.
422
+ */
423
+ export function foreignItemCatalogDirs(config) {
424
+ const dirs = [];
425
+ for (const rel of itemCatalogRelationships(config)) {
426
+ const root = config.paths.foreignCache;
427
+ const cached =
428
+ fs.existsSync(root) ?
429
+ fs
430
+ .readdirSync(root)
431
+ .filter((name) => name.startsWith(`${rel.id}@`))
432
+ .map((name) => path.join(root, name))
433
+ .filter(isComplete)
434
+ : [];
435
+ if (!cached.length) {
436
+ throw new Error(
437
+ `${rel.id} declares \`itemCatalog: true\` but has not been ` +
438
+ `fetched. Run \`content-build deps fetch\` first.`,
439
+ );
440
+ }
441
+ // Newest last wins if several versions are cached; a fetch always
442
+ // writes the currently declared one, so that is the one to use.
443
+ cached.sort();
444
+ const items = itemsDir(cached[cached.length - 1]);
445
+ for (const name of fs.readdirSync(items)) {
446
+ dirs.push(path.join(items, name));
447
+ }
448
+ }
449
+ return dirs;
450
+ }