@heroiclands/package-build 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0a2ef1e: **The Foundry manifest is generated from configuration. The template is
8
+ retired.**
9
+
10
+ `package-build manifest` writes `system.json` / `module.json` from
11
+ `packageBuild.manifest` plus the facts the build already holds. There is no
12
+ `assets/templates/*.template.json`, and the template-reading path is removed
13
+ rather than left as a fallback: `writeFoundryManifest`, `stampManifest` and
14
+ `artifactFromTemplate` are gone, replaced by `buildManifest`, `writeManifest`
15
+ and `manifestPacks`.
16
+
17
+ The manifest was the one build input still hand-authored JSON, per repository,
18
+ with no schema and nothing checking it — and it declared facts the configuration
19
+ already declared. SoHL's pack list was written twice, in two formats, with
20
+ nothing checking the pairs agreed; `sohl-kethira-basic` hand-maintained its whole
21
+ `module.json`, and its `download` named an older version than the module claimed.
22
+
23
+ Three kinds of key end up in the result:
24
+
25
+ - **Declared** — `packageBuild.manifest`, emitted unchanged, so a key Foundry
26
+ adds in a later version needs no release of this package. The block is
27
+ deliberately not key-checked; pass-through and unknown-key checking cannot
28
+ coexist, which is why it is its own block rather than spread across
29
+ `packageBuild:` where the keys around it are still checked.
30
+ - **Derived** — `id`, `version`, `url`, `bugs`, `manifest`, `download`,
31
+ `compatibility`, `relationships`, `packs`. Declaring one is an **error**
32
+ naming the key and where the value actually comes from, not an override: an
33
+ authored copy would be silently overwritten and the two would disagree with
34
+ nothing to say so.
35
+ - **Computed** — namespaced `flags` from a module named in
36
+ `packageBuild.manifestFlags`, merged over any declared. That is for a value a
37
+ repository must work out rather than state — SoHL's credits `@UUID` only
38
+ exists once the content tree has been walked.
39
+
40
+ `packs` comes from the **one** pack list at the top level of
41
+ `content-build.config.yaml`, with companions flattened in. Give each pack the
42
+ `label` Foundry should show; everything else is derived.
43
+
44
+ Requires `@heroiclands/content-build` **1.0.0**, which moved `compatibility` and
45
+ `relationships` to the top level (content-build#50).
46
+
47
+ Verified against SoHL's real package: the generated manifest is **byte-identical**
48
+ to what its template pipeline produces today, all 24 keys, key order included.
49
+
3
50
  ## 0.2.1
4
51
 
5
52
  ### Patch Changes
package/README.md CHANGED
@@ -95,8 +95,74 @@ packageBuild:
95
95
  deploy:
96
96
  # Prefix of the shared SFTP override variables. Default `SOHL`.
97
97
  envPrefix: SOHL
98
+
99
+ # Optional. A module exporting `flags(config)` returning namespaced Foundry
100
+ # flags the repository has to *compute* — an address that only exists once
101
+ # the content tree has been walked, say. Merged over any declared below.
102
+ manifestFlags: ./utils/manifest-flags.mjs
103
+
104
+ # The Foundry package manifest. Emitted as declared, so a key Foundry adds in
105
+ # a later version needs no release of this package.
106
+ manifest:
107
+ title: Song of Heroic Lands
108
+ description: <p>…</p>
109
+ license: LICENSE.md
110
+ readme: README.md
111
+ authors:
112
+ - { name: Toasty, discord: "toasty#8538" }
113
+ esmodules: [sohl.js]
114
+ styles: [css/sohl.css]
115
+ languages:
116
+ - { lang: en, name: English, path: lang/en.json }
117
+ documentTypes:
118
+ Item:
119
+ skill: { htmlFields: [notes, docHtml] }
120
+ packFolders:
121
+ - name: Song of Heroic Lands
122
+ sorting: m
123
+ color: "#094fcb"
124
+ packs: [items, journals, actors, macros, scenes, adventures]
125
+ media:
126
+ - { type: logo, url: systems/sohl/assets/ui/logo.webp }
127
+ socket: true
128
+ grid: { distance: 5, units: ft }
129
+ primaryTokenAttribute: health
98
130
  ```
99
131
 
132
+ ### The manifest is generated, not stamped
133
+
134
+ `package-build manifest` writes `system.json` / `module.json` into the stage.
135
+ **There is no template file.** A manifest used to be hand-authored JSON that the
136
+ build stamped a few fields into — the one build input still written by hand, per
137
+ repository, with no schema and nothing checking it. It also declared facts the
138
+ configuration already declared: the pack list twice, in two formats, with
139
+ nothing checking the pairs agreed.
140
+
141
+ Three kinds of key end up in the result:
142
+
143
+ | Kind | Where it comes from |
144
+ | ------------ | ------------------------------------------------------------------------------------------------- |
145
+ | **Declared** | `packageBuild.manifest`, emitted unchanged |
146
+ | **Derived** | `id`, `version`, `url`, `bugs`, `manifest`, `download`, `compatibility`, `relationships`, `packs` |
147
+ | **Computed** | namespaced `flags` from `manifestFlags`, merged over any declared |
148
+
149
+ **Declaring a derived key is an error, not an override.** An authored `version`
150
+ would look authoritative, sit there unread, and disagree with the shipped
151
+ package forever; the build says so, naming the key and where the value actually
152
+ comes from.
153
+
154
+ `packs` is derived from the **one** pack list at the top level of
155
+ `content-build.config.yaml` — each entry's `label`, `type`, `name` and
156
+ `private`, plus a `system` from `stats.systemId` and a `path` of
157
+ `packs/<name>`. Companions are flattened in, because Foundry sees no difference:
158
+ a companion is only a pack written by another pass rather than one of its own.
159
+ Give each pack the `label` you want Foundry to show.
160
+
161
+ `compatibility` and `relationships` are read from the **top level** of the
162
+ shared configuration, not from this section — content-build consumes them
163
+ (`supportedCoreVersion`, and a module'''s `stats.systemVersion`) and the
164
+ dependency runs one way.
165
+
100
166
  **Why one file and not two.** Two of the values this package needs —
101
167
  `packageKind` and `foundryPackage` — are already declared for `content-build`. A
102
168
  second config file would restate them, which is two places for one fact; that is
@@ -121,6 +187,7 @@ other's schema — they split by input, and the dependency runs one way.
121
187
  ```
122
188
  npx package-build clean [--distclean]
123
189
  npx package-build assets
190
+ npx package-build manifest
124
191
  npx package-build lang check
125
192
  npx package-build release
126
193
  npx package-build deploy <stage>
@@ -42,6 +42,7 @@
42
42
  * Usage:
43
43
  * npx package-build clean [--distclean]
44
44
  * npx package-build assets
45
+ * npx package-build manifest
45
46
  * npx package-build lang check
46
47
  * npx package-build release
47
48
  * npx package-build deploy <stage>
@@ -62,9 +63,11 @@ import yargs from "yargs";
62
63
  import { hideBin } from "yargs/helpers";
63
64
 
64
65
  import { loadPackageBuildConfig } from "../config.mjs";
66
+ import { loadPackConfig } from "@heroiclands/content-build/engine/pack-config";
65
67
  import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
66
68
  import { validateLangSource } from "../lang.mjs";
67
69
  import { packRelease } from "../release.mjs";
70
+ import { writeManifest } from "../manifest.mjs";
68
71
  import { deployStage } from "../deploy.mjs";
69
72
 
70
73
  /**
@@ -212,6 +215,70 @@ function assetsCommand() {
212
215
  };
213
216
  }
214
217
 
218
+ /**
219
+ * `manifest` — generate `system.json` / `module.json` into the build stage.
220
+ *
221
+ * There is no template to read. Everything the manifest needs is either
222
+ * declared in `packageBuild.manifest`, derived from configuration this
223
+ * repository already carries, or computed by a module the repository names in
224
+ * `packageBuild.manifestFlags` — for a namespaced flag it has to work out, such
225
+ * as the compendium address of a document that only exists once the content
226
+ * tree has been walked.
227
+ *
228
+ * @returns {object} The yargs command module.
229
+ */
230
+ function manifestCommand() {
231
+ return {
232
+ command: "manifest",
233
+ describe: "Generate the Foundry package manifest",
234
+ builder: (y) => y,
235
+ handler: handler(async () => {
236
+ const config = loadPackageBuildConfig();
237
+ const shared = loadPackConfig();
238
+ const packageJson = JSON.parse(
239
+ fs.readFileSync(
240
+ path.join(config.rootDir, "package.json"),
241
+ "utf8",
242
+ ),
243
+ );
244
+
245
+ let flags;
246
+ if (config.manifestFlags) {
247
+ const module = await import(
248
+ `file://${config.manifestFlags}`
249
+ ).catch((err) =>
250
+ die(
251
+ `cannot load \`packageBuild.manifestFlags\` ` +
252
+ `(${config.manifestFlags}): ${err.message}`,
253
+ ),
254
+ );
255
+ if (typeof module.flags !== "function") {
256
+ die(
257
+ `\`packageBuild.manifestFlags\` ` +
258
+ `(${config.manifestFlags}) exports no \`flags\` ` +
259
+ `function. It must export ` +
260
+ `\`flags(config) -> Record<string, object>\`.`,
261
+ );
262
+ }
263
+ flags = await module.flags(shared);
264
+ }
265
+
266
+ const { path: written, manifest } = await writeManifest({
267
+ config: shared,
268
+ packageJson,
269
+ artifact: config.artifact,
270
+ outDir: path.join(config.rootDir, config.stageDir),
271
+ flags,
272
+ });
273
+ console.log(
274
+ `✅ Wrote ${path.relative(config.rootDir, written)} ` +
275
+ `(${Object.keys(manifest).length} keys, ` +
276
+ `${manifest.packs.length} packs).`,
277
+ );
278
+ }),
279
+ };
280
+ }
281
+
215
282
  /**
216
283
  * `lang check` — verify every localization file survives `expandObject`.
217
284
  *
@@ -352,6 +419,7 @@ yargs(hideBin(process.argv))
352
419
  .scriptName("package-build")
353
420
  .command(cleanCommand())
354
421
  .command(assetsCommand())
422
+ .command(manifestCommand())
355
423
  .command(langCommand())
356
424
  .command(releaseCommand())
357
425
  .command(deployCommand())
package/config.mjs CHANGED
@@ -63,11 +63,36 @@ const SECTION_KEYS = [
63
63
  "stageDir",
64
64
  "assets",
65
65
  "assetTransform",
66
+ "manifest",
67
+ "manifestFlags",
66
68
  "clean",
67
69
  "lang",
68
70
  "deploy",
69
71
  "release",
70
72
  ];
73
+
74
+ /**
75
+ * Manifest keys a repository may **not** declare, because the build derives
76
+ * them and would only overwrite what was written.
77
+ *
78
+ * Silently overwriting is the failure this list exists to prevent: a
79
+ * `version` typed into the configuration would look authoritative, sit there
80
+ * unread, and disagree with the shipped package forever. Declaring one is an
81
+ * error naming the key and where the value actually comes from.
82
+ *
83
+ * @type {Readonly<Record<string, string>>}
84
+ */
85
+ export const DERIVED_MANIFEST_KEYS = Object.freeze({
86
+ id: "`foundryPackage`, itself derived from package.json `name`",
87
+ version: "package.json `version`",
88
+ url: "package.json `repository`",
89
+ bugs: "package.json `repository`",
90
+ manifest: "package.json `repository` and the release tag",
91
+ download: "package.json `repository` and the release tag",
92
+ compatibility: "the top level of content-build.config.yaml",
93
+ relationships: "the top level of content-build.config.yaml",
94
+ packs: "the `packs` list at the top level of content-build.config.yaml",
95
+ });
71
96
  const ASSET_KEYS = ["from", "to"];
72
97
  const CLEAN_KEYS = ["extra"];
73
98
  const LANG_KEYS = ["sources", "help"];
@@ -156,6 +181,41 @@ function normalizeAsset(value, index) {
156
181
  });
157
182
  }
158
183
 
184
+ /**
185
+ * Validate the manifest specification.
186
+ *
187
+ * **Deliberately not key-checked.** Everything a repository declares here is
188
+ * emitted into the manifest unchanged, so a key Foundry adds in a later version
189
+ * can be declared without waiting for a release of this package. The only rule
190
+ * is the one that has a wrong answer rather than an unknown one: a key the
191
+ * build *derives* must not also be authored, because the authored value would
192
+ * be silently overwritten.
193
+ *
194
+ * That is also why it is its own block rather than being spread across
195
+ * `packageBuild:` directly — pass-through and unknown-key checking cannot
196
+ * coexist in one mapping, and the keys around it are worth checking.
197
+ *
198
+ * @param {unknown} value - The `manifest` block, or `undefined`.
199
+ * @returns {Readonly<Record<string, unknown>>} It, frozen; `{}` when absent.
200
+ */
201
+ function normalizeManifest(value) {
202
+ if (value === undefined) return Object.freeze({});
203
+ if (!isMapping(value)) fail("packageBuild.manifest", "must be a mapping");
204
+ const input = /** @type {Record<string, unknown>} */ (value);
205
+
206
+ for (const [key, source] of Object.entries(DERIVED_MANIFEST_KEYS)) {
207
+ if (input[key] !== undefined) {
208
+ fail(
209
+ `packageBuild.manifest.${key}`,
210
+ `is derived from ${source} and must not be declared — it ` +
211
+ `would be overwritten, and the two would disagree with ` +
212
+ `nothing to say so`,
213
+ );
214
+ }
215
+ }
216
+ return Object.freeze(structuredClone(input));
217
+ }
218
+
159
219
  /**
160
220
  * The resolved `packageBuild` section, every optional half filled in.
161
221
  *
@@ -167,6 +227,11 @@ function normalizeAsset(value, index) {
167
227
  * @property {string} stageDir The staged package root, relative to
168
228
  * `rootDir`. Every asset `to:` lands under it.
169
229
  * @property {readonly Readonly<AssetSpec>[]} assets
230
+ * @property {Readonly<Record<string, unknown>>} manifest The manifest
231
+ * specification, emitted as declared.
232
+ * @property {string|null} manifestFlags Module to load a `flags` function
233
+ * from, for namespaced flags a repository has
234
+ * to compute. `null` when it declares none.
170
235
  * @property {string|null} assetTransform Module to load a `transform` from,
171
236
  * resolved against `rootDir`. `null` when
172
237
  * the repository stages assets verbatim.
@@ -280,6 +345,17 @@ export function resolvePackageBuildConfig(shared) {
280
345
  "packageBuild.assetTransform",
281
346
  ),
282
347
  ),
348
+ manifest: normalizeManifest(section.manifest),
349
+ manifestFlags:
350
+ section.manifestFlags === undefined ?
351
+ null
352
+ : path.resolve(
353
+ shared.rootDir,
354
+ requireNonEmptyString(
355
+ section.manifestFlags,
356
+ "packageBuild.manifestFlags",
357
+ ),
358
+ ),
283
359
  cleanExtra: Object.freeze(cleanExtra),
284
360
  langSources:
285
361
  langInput.sources === undefined ?
package/manifest.mjs CHANGED
@@ -15,26 +15,35 @@
15
15
  * Building the Foundry package manifest — `system.json` or `module.json`.
16
16
  *
17
17
  * Foundry defines exactly two package kinds, and a repository is one of them,
18
- * so there is one job here with two spellings: read the repository's manifest
19
- * *template*, stamp the facts that must not be transcribed, and write the
20
- * result into the build stage.
21
- *
22
- * **The stamped fields are the ones a human copy rots.** A manifest carries the
23
- * version, the repository addresses, and the two release URLs Foundry fetches
24
- * to check for and install an update. Every one of them is already stated
25
- * somewhere that owns it `package.json` and a second, hand-maintained copy
26
- * in the template drifts the moment a release is cut. `sohl-kethira-basic`
27
- * hand-maintains its whole `module.json`, and its `download` still names an
28
- * older version than the module claims.
18
+ * so there is one job here with two spellings: assemble the manifest from the
19
+ * repository's configuration and write it into the build stage.
20
+ *
21
+ * **There is no template any more.** A manifest used to be a hand-authored
22
+ * `system.template.json` that this module stamped a few fields into which
23
+ * made it the one build input still written as JSON, by hand, per repository,
24
+ * with no schema and nothing checking it. Worse, it declared facts the
25
+ * configuration also declared: the pack list twice, in two formats, with
26
+ * nothing checking that the pairs agreed. `sohl-kethira-basic` hand-maintained
27
+ * its whole `module.json`, and its `download` named an older version than the
28
+ * module claimed.
29
+ *
30
+ * So the manifest is generated (#9). Three kinds of key end up in it:
31
+ *
32
+ * - **Declared** — the `packageBuild.manifest` block, emitted unchanged, so a
33
+ * key Foundry adds in a later version needs no release of this package.
34
+ * - **Derived** — the identity, the version, the release addresses, the
35
+ * compatibility ranges and the pack list. Declaring one of these is an error
36
+ * rather than an override: the authored copy would be silently overwritten.
37
+ * - **Computed** — namespaced `flags` a repository works out for itself.
29
38
  *
30
39
  * **Nothing here invents an address.** The repository URL is read from
31
- * `package.json`'s `repository` field, normalised, and everything else is
32
- * derived from it. A manifest that advertised another package's URLs would send
33
- * Foundry to the wrong release on every update check — which is exactly what a
34
- * template copied between repositories produces.
40
+ * `package.json`'s `repository` field, normalised, and everything else derived
41
+ * from it. A manifest advertising another package's URLs would send Foundry to
42
+ * the wrong release on every update check — exactly what a template copied
43
+ * between repositories produced.
35
44
  *
36
45
  * The rules are pure functions over data. I/O is confined to
37
- * {@link writeFoundryManifest}, which is the only export that touches disk.
46
+ * {@link writeManifest}, which is the only export that touches disk.
38
47
  *
39
48
  * @module
40
49
  */
@@ -52,34 +61,6 @@ import path from "node:path";
52
61
  */
53
62
  export const ARTIFACTS = Object.freeze(["system", "module"]);
54
63
 
55
- /**
56
- * Which artifact a template file builds.
57
- *
58
- * Inferred from the template's own name so the usual case takes no
59
- * configuration: a repository that ships `system.template.json` is a system,
60
- * and one that ships `module.template.json` is a module. That is the same pair
61
- * `@heroiclands/content-build` resolves a package manifest from, so the two
62
- * cannot disagree about what a repository is.
63
- *
64
- * @param {string} templatePath - Path to the manifest template.
65
- * @returns {"system"|"module"} The artifact name.
66
- * @throws {TypeError} When the name identifies neither kind — a template called
67
- * something else leaves nothing to infer from, and guessing would silently
68
- * emit a manifest Foundry never looks for.
69
- */
70
- export function artifactFromTemplate(templatePath) {
71
- const base = path.basename(String(templatePath ?? ""));
72
- const artifact = ARTIFACTS.find((a) => base.startsWith(`${a}.`));
73
- if (!artifact) {
74
- throw new TypeError(
75
- `Cannot tell whether "${base}" builds a system or a module. ` +
76
- `Name it system.template.json or module.template.json, or pass ` +
77
- `\`artifact\` explicitly.`,
78
- );
79
- }
80
- return artifact;
81
- }
82
-
83
64
  /**
84
65
  * The repository's web address, from whatever spelling `package.json` carries.
85
66
  *
@@ -138,80 +119,155 @@ export function releaseUrls({ repoUrl, version, artifact }) {
138
119
  }
139
120
 
140
121
  /**
141
- * Stamp a manifest template with the facts that must not be transcribed.
122
+ * The order the manifest's keys are written in.
142
123
  *
143
- * Pure: the template is not mutated, and the result is a new object.
124
+ * Foundry does not care, but a human reading a diff does, and the generated
125
+ * file has to be comparable against the hand-authored template it replaces —
126
+ * which is only possible if the order is fixed rather than incidental to which
127
+ * keys a repository happened to declare. Anything not listed keeps its declared
128
+ * order, after these.
144
129
  *
145
- * `flags` is merged **per namespace**, not wholesale, so a template may carry
146
- * its own keys under the same namespace and keep them. A caller supplies
147
- * whatever its package needs there — the credits journal's UUID, the settings
148
- * sidebar's links — because those are facts about one package, not about being
149
- * a Foundry package.
130
+ * @type {readonly string[]}
131
+ */
132
+ const MANIFEST_KEY_ORDER = Object.freeze([
133
+ "id",
134
+ "title",
135
+ "description",
136
+ "version",
137
+ "authors",
138
+ "license",
139
+ "readme",
140
+ "changelog",
141
+ "flags",
142
+ "compatibility",
143
+ "relationships",
144
+ "esmodules",
145
+ "styles",
146
+ "languages",
147
+ "documentTypes",
148
+ "packFolders",
149
+ "packs",
150
+ "media",
151
+ "socket",
152
+ "grid",
153
+ "primaryTokenAttribute",
154
+ "url",
155
+ "bugs",
156
+ "manifest",
157
+ "download",
158
+ ]);
159
+
160
+ /**
161
+ * The manifest's `packs`, derived from the one pack list the build already has.
150
162
  *
151
- * @param {object} template - The parsed manifest template.
152
- * @param {object} opts
153
- * @param {string} opts.version - The version being built.
154
- * @param {string} opts.repoUrl - Normalised repository URL.
155
- * @param {"system"|"module"} opts.artifact - Which artifact is shipped.
156
- * @param {Record<string, object>} [opts.flags] - Namespaced flags to merge.
157
- * @returns {object} The stamped manifest.
163
+ * The two used to be written separately — `content-build.config.yaml` declared
164
+ * a pack's name and type, and the manifest template declared them again beside
165
+ * a label, a path and a system id, with nothing checking that the pairs agreed.
166
+ * They are one list now.
167
+ *
168
+ * Companions are flattened in, because Foundry sees no difference: a companion
169
+ * is only a pack written by another pass rather than one of its own, and it
170
+ * ships as an ordinary compendium. The order matches `packDirectories`, so the
171
+ * manifest lists packs in the order the build compiles them.
172
+ *
173
+ * @param {object} config - The resolved content-build configuration.
174
+ * @returns {object[]} The manifest's `packs` array.
158
175
  */
159
- export function stampManifest(template, { version, repoUrl, artifact, flags }) {
160
- const stamped = {
161
- ...template,
162
- version,
163
- ...releaseUrls({ repoUrl, version, artifact }),
176
+ export function manifestPacks(config) {
177
+ const flatten = (pack) => [
178
+ pack,
179
+ ...(pack.companions ?? []).flatMap(flatten),
180
+ ];
181
+ return config.packs.flatMap(flatten).map((pack) => ({
182
+ label: pack.label,
183
+ type: pack.type,
184
+ name: pack.name,
185
+ system: config.stats.systemId,
186
+ path: `packs/${pack.name}`,
187
+ private: pack.private,
188
+ }));
189
+ }
190
+
191
+ /**
192
+ * Build a Foundry package manifest from the resolved configuration.
193
+ *
194
+ * Three kinds of key end up in the result:
195
+ *
196
+ * - **Declared** — everything in `packageBuild.manifest`, emitted unchanged, so
197
+ * a key Foundry adds later needs no release of this package.
198
+ * - **Derived** — the identity, the release addresses, the version, the Foundry
199
+ * and system compatibility ranges, and the pack list. These are refused if
200
+ * also declared: an authored copy would be overwritten and the two would
201
+ * disagree with nothing to say so.
202
+ * - **Computed** — namespaced `flags` a repository works out for itself, merged
203
+ * over any it declared.
204
+ *
205
+ * @param {object} options - Inputs.
206
+ * @param {object} options.config - The resolved content-build configuration.
207
+ * @param {object} options.packageJson - The repository's `package.json`.
208
+ * @param {string} options.artifact - `system` or `module`.
209
+ * @param {Record<string, object>} [options.flags] - Namespaced flags to merge.
210
+ * @returns {object} The manifest, ready to serialise.
211
+ */
212
+ export function buildManifest({ config, packageJson, artifact, flags }) {
213
+ const declared = config.packageBuild?.manifest ?? {};
214
+ const repoUrl = normalizeRepoUrl(packageJson.repository);
215
+
216
+ const derived = {
217
+ id: config.foundryPackage,
218
+ version: packageJson.version,
219
+ packs: manifestPacks(config),
220
+ ...releaseUrls({ repoUrl, version: packageJson.version, artifact }),
164
221
  };
222
+ if (config.compatibility) derived.compatibility = config.compatibility;
223
+ if (config.relationships && Object.keys(config.relationships).length) {
224
+ derived.relationships = config.relationships;
225
+ }
226
+
227
+ const merged = { ...declared, ...derived };
165
228
 
166
229
  if (flags && Object.keys(flags).length) {
167
- stamped.flags = { ...(template.flags ?? {}) };
230
+ merged.flags = { ...(declared.flags ?? {}) };
168
231
  for (const [namespace, values] of Object.entries(flags)) {
169
- stamped.flags[namespace] = {
170
- ...(template.flags?.[namespace] ?? {}),
232
+ merged.flags[namespace] = {
233
+ ...(declared.flags?.[namespace] ?? {}),
171
234
  ...values,
172
235
  };
173
236
  }
174
237
  }
175
238
 
176
- return stamped;
239
+ // Ordered, so the generated file diffs against the template it replaces.
240
+ const ordered = {};
241
+ for (const key of MANIFEST_KEY_ORDER) {
242
+ if (merged[key] !== undefined) ordered[key] = merged[key];
243
+ }
244
+ for (const [key, value] of Object.entries(merged)) {
245
+ if (!(key in ordered)) ordered[key] = value;
246
+ }
247
+ return ordered;
177
248
  }
178
249
 
179
250
  /**
180
- * Read a manifest template, stamp it, and write the result into the stage.
251
+ * Write the generated manifest into the staged package.
181
252
  *
182
- * The only export here that touches disk. Everything it decides is decided by
183
- * the pure functions above, so the rules stay testable without a filesystem.
184
- *
185
- * @param {object} opts
186
- * @param {string} opts.templatePath - The manifest template to read.
187
- * @param {object} opts.packageJson - The parsed `package.json`, which owns the
188
- * version and the repository address.
189
- * @param {string} opts.outDir - Directory to write the manifest into, created
190
- * if absent.
191
- * @param {"system"|"module"} [opts.artifact] - Overrides the artifact inferred
192
- * from the template's name.
193
- * @param {Record<string, object>} [opts.flags] - Namespaced flags to merge.
194
- * @returns {Promise<{path: string, manifest: object}>} Where it was written,
195
- * and what was written.
253
+ * @param {object} options - As {@link buildManifest}, plus where to write.
254
+ * @param {object} options.config - The resolved content-build configuration.
255
+ * @param {object} options.packageJson - The repository's `package.json`.
256
+ * @param {string} options.artifact - `system` or `module`.
257
+ * @param {string} options.outDir - Directory to write into.
258
+ * @param {Record<string, object>} [options.flags] - Namespaced flags to merge.
259
+ * @returns {Promise<{path: string, manifest: object}>} Where it went, and what.
196
260
  */
197
- export async function writeFoundryManifest({
198
- templatePath,
261
+ export async function writeManifest({
262
+ config,
199
263
  packageJson,
264
+ artifact,
200
265
  outDir,
201
- artifact = undefined,
202
- flags = undefined,
266
+ flags,
203
267
  }) {
204
- const kind = artifact ?? artifactFromTemplate(templatePath);
205
- const template = JSON.parse(await fs.readFile(templatePath, "utf8"));
206
- const manifest = stampManifest(template, {
207
- version: packageJson.version,
208
- repoUrl: normalizeRepoUrl(packageJson.repository),
209
- artifact: kind,
210
- flags,
211
- });
212
-
268
+ const manifest = buildManifest({ config, packageJson, artifact, flags });
213
269
  await fs.mkdir(outDir, { recursive: true });
214
- const outPath = path.join(outDir, `${kind}.json`);
270
+ const outPath = path.join(outDir, `${artifact}.json`);
215
271
  // Trailing newline: the file is committed to a release archive and read by
216
272
  // humans as often as by Foundry.
217
273
  await fs.writeFile(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -72,7 +72,7 @@
72
72
  "prepare": "git config core.hooksPath .githooks || true"
73
73
  },
74
74
  "dependencies": {
75
- "@heroiclands/content-build": "^0.15.0",
75
+ "@heroiclands/content-build": "^1.0.0",
76
76
  "acorn": "^8.18.0",
77
77
  "archiver": "^8.0.0",
78
78
  "dotenv": "^17.2.3",
@@ -9,6 +9,11 @@
9
9
  * @property {string} stageDir The staged package root, relative to
10
10
  * `rootDir`. Every asset `to:` lands under it.
11
11
  * @property {readonly Readonly<AssetSpec>[]} assets
12
+ * @property {Readonly<Record<string, unknown>>} manifest The manifest
13
+ * specification, emitted as declared.
14
+ * @property {string|null} manifestFlags Module to load a `flags` function
15
+ * from, for namespaced flags a repository has
16
+ * to compute. `null` when it declares none.
12
17
  * @property {string|null} assetTransform Module to load a `transform` from,
13
18
  * resolved against `rootDir`. `null` when
14
19
  * the repository stages assets verbatim.
@@ -44,6 +49,18 @@ export function resolvePackageBuildConfig(shared: object): Readonly<PackageBuild
44
49
  * declares something malformed.
45
50
  */
46
51
  export function loadPackageBuildConfig(): Readonly<PackageBuildConfig>;
52
+ /**
53
+ * Manifest keys a repository may **not** declare, because the build derives
54
+ * them and would only overwrite what was written.
55
+ *
56
+ * Silently overwriting is the failure this list exists to prevent: a
57
+ * `version` typed into the configuration would look authoritative, sit there
58
+ * unread, and disagree with the shipped package forever. Declaring one is an
59
+ * error naming the key and where the value actually comes from.
60
+ *
61
+ * @type {Readonly<Record<string, string>>}
62
+ */
63
+ export const DERIVED_MANIFEST_KEYS: Readonly<Record<string, string>>;
47
64
  /**
48
65
  * One staging copy: a source path in the repository, and where it lands under
49
66
  * the staged package root.
@@ -84,6 +101,17 @@ export type PackageBuildConfig = {
84
101
  */
85
102
  stageDir: string;
86
103
  assets: readonly Readonly<AssetSpec>[];
104
+ /**
105
+ * The manifest
106
+ * specification, emitted as declared.
107
+ */
108
+ manifest: Readonly<Record<string, unknown>>;
109
+ /**
110
+ * Module to load a `flags` function
111
+ * from, for namespaced flags a repository has
112
+ * to compute. `null` when it declares none.
113
+ */
114
+ manifestFlags: string | null;
87
115
  /**
88
116
  * Module to load a `transform` from,
89
117
  * resolved against `rootDir`. `null` when
@@ -1,19 +1,3 @@
1
- /**
2
- * Which artifact a template file builds.
3
- *
4
- * Inferred from the template's own name so the usual case takes no
5
- * configuration: a repository that ships `system.template.json` is a system,
6
- * and one that ships `module.template.json` is a module. That is the same pair
7
- * `@heroiclands/content-build` resolves a package manifest from, so the two
8
- * cannot disagree about what a repository is.
9
- *
10
- * @param {string} templatePath - Path to the manifest template.
11
- * @returns {"system"|"module"} The artifact name.
12
- * @throws {TypeError} When the name identifies neither kind — a template called
13
- * something else leaves nothing to infer from, and guessing would silently
14
- * emit a manifest Foundry never looks for.
15
- */
16
- export function artifactFromTemplate(templatePath: string): "system" | "module";
17
1
  /**
18
2
  * The repository's web address, from whatever spelling `package.json` carries.
19
3
  *
@@ -59,53 +43,65 @@ export function releaseUrls({ repoUrl, version, artifact }: {
59
43
  download: string;
60
44
  };
61
45
  /**
62
- * Stamp a manifest template with the facts that must not be transcribed.
46
+ * The manifest's `packs`, derived from the one pack list the build already has.
63
47
  *
64
- * Pure: the template is not mutated, and the result is a new object.
48
+ * The two used to be written separately `content-build.config.yaml` declared
49
+ * a pack's name and type, and the manifest template declared them again beside
50
+ * a label, a path and a system id, with nothing checking that the pairs agreed.
51
+ * They are one list now.
65
52
  *
66
- * `flags` is merged **per namespace**, not wholesale, so a template may carry
67
- * its own keys under the same namespace and keep them. A caller supplies
68
- * whatever its package needs there the credits journal's UUID, the settings
69
- * sidebar's links because those are facts about one package, not about being
70
- * a Foundry package.
53
+ * Companions are flattened in, because Foundry sees no difference: a companion
54
+ * is only a pack written by another pass rather than one of its own, and it
55
+ * ships as an ordinary compendium. The order matches `packDirectories`, so the
56
+ * manifest lists packs in the order the build compiles them.
71
57
  *
72
- * @param {object} template - The parsed manifest template.
73
- * @param {object} opts
74
- * @param {string} opts.version - The version being built.
75
- * @param {string} opts.repoUrl - Normalised repository URL.
76
- * @param {"system"|"module"} opts.artifact - Which artifact is shipped.
77
- * @param {Record<string, object>} [opts.flags] - Namespaced flags to merge.
78
- * @returns {object} The stamped manifest.
58
+ * @param {object} config - The resolved content-build configuration.
59
+ * @returns {object[]} The manifest's `packs` array.
79
60
  */
80
- export function stampManifest(template: object, { version, repoUrl, artifact, flags }: {
81
- version: string;
82
- repoUrl: string;
83
- artifact: "system" | "module";
61
+ export function manifestPacks(config: object): object[];
62
+ /**
63
+ * Build a Foundry package manifest from the resolved configuration.
64
+ *
65
+ * Three kinds of key end up in the result:
66
+ *
67
+ * - **Declared** — everything in `packageBuild.manifest`, emitted unchanged, so
68
+ * a key Foundry adds later needs no release of this package.
69
+ * - **Derived** — the identity, the release addresses, the version, the Foundry
70
+ * and system compatibility ranges, and the pack list. These are refused if
71
+ * also declared: an authored copy would be overwritten and the two would
72
+ * disagree with nothing to say so.
73
+ * - **Computed** — namespaced `flags` a repository works out for itself, merged
74
+ * over any it declared.
75
+ *
76
+ * @param {object} options - Inputs.
77
+ * @param {object} options.config - The resolved content-build configuration.
78
+ * @param {object} options.packageJson - The repository's `package.json`.
79
+ * @param {string} options.artifact - `system` or `module`.
80
+ * @param {Record<string, object>} [options.flags] - Namespaced flags to merge.
81
+ * @returns {object} The manifest, ready to serialise.
82
+ */
83
+ export function buildManifest({ config, packageJson, artifact, flags }: {
84
+ config: object;
85
+ packageJson: object;
86
+ artifact: string;
84
87
  flags?: Record<string, object> | undefined;
85
88
  }): object;
86
89
  /**
87
- * Read a manifest template, stamp it, and write the result into the stage.
90
+ * Write the generated manifest into the staged package.
88
91
  *
89
- * The only export here that touches disk. Everything it decides is decided by
90
- * the pure functions above, so the rules stay testable without a filesystem.
91
- *
92
- * @param {object} opts
93
- * @param {string} opts.templatePath - The manifest template to read.
94
- * @param {object} opts.packageJson - The parsed `package.json`, which owns the
95
- * version and the repository address.
96
- * @param {string} opts.outDir - Directory to write the manifest into, created
97
- * if absent.
98
- * @param {"system"|"module"} [opts.artifact] - Overrides the artifact inferred
99
- * from the template's name.
100
- * @param {Record<string, object>} [opts.flags] - Namespaced flags to merge.
101
- * @returns {Promise<{path: string, manifest: object}>} Where it was written,
102
- * and what was written.
92
+ * @param {object} options - As {@link buildManifest}, plus where to write.
93
+ * @param {object} options.config - The resolved content-build configuration.
94
+ * @param {object} options.packageJson - The repository's `package.json`.
95
+ * @param {string} options.artifact - `system` or `module`.
96
+ * @param {string} options.outDir - Directory to write into.
97
+ * @param {Record<string, object>} [options.flags] - Namespaced flags to merge.
98
+ * @returns {Promise<{path: string, manifest: object}>} Where it went, and what.
103
99
  */
104
- export function writeFoundryManifest({ templatePath, packageJson, outDir, artifact, flags, }: {
105
- templatePath: string;
100
+ export function writeManifest({ config, packageJson, artifact, outDir, flags, }: {
101
+ config: object;
106
102
  packageJson: object;
103
+ artifact: string;
107
104
  outDir: string;
108
- artifact?: "module" | "system" | undefined;
109
105
  flags?: Record<string, object> | undefined;
110
106
  }): Promise<{
111
107
  path: string;