@heroiclands/package-build 0.1.0 → 0.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.
package/README.md CHANGED
@@ -56,6 +56,104 @@ The whole of assemble → validate → ship, one subpath each:
56
56
  - **`text`** — locating a literal inside a file, so a finding names the line and
57
57
  column it is about.
58
58
 
59
+ ## Configure
60
+
61
+ A repository declares its build in **one** file — `content-build.config.yaml`,
62
+ the same one `content-build` reads — and this package takes its settings from
63
+ the reserved `packageBuild:` section:
64
+
65
+ ```yaml
66
+ # Read from the top level, not restated below.
67
+ packageKind: modules
68
+ foundryPackage: sohl-thalorna
69
+
70
+ packageBuild:
71
+ # Where the package is assembled. Every `to:` below is relative to it, so a
72
+ # table reads `lang`, not `build/stage/lang`.
73
+ stageDir: build/stage
74
+
75
+ assets:
76
+ - { from: lang, to: lang }
77
+ - { from: assets/icons, to: assets/icons }
78
+ - { from: LICENSE.md, to: LICENSE.md }
79
+
80
+ # Optional. A module exporting `transform(sourcePath) -> string | null`,
81
+ # applied to every staged file — `null` copies it verbatim. This is the one
82
+ # genuine piece of code in staging, and it stays the repository's: SoHL
83
+ # rewrites each SVG's hard-coded fill so icons follow the Foundry theme.
84
+ assetTransform: ./utils/svg-theme.mjs
85
+
86
+ clean:
87
+ # Beyond the conventional build artifacts, which the library already knows.
88
+ extra: [site/content, site/public, site/resources]
89
+
90
+ lang:
91
+ sources: lang/*.json
92
+ # Printed after a failure — where this repository documents its key rules.
93
+ help: See kb/dev-docs/reference/localization-keys.md.
94
+
95
+ deploy:
96
+ # Prefix of the shared SFTP override variables. Default `SOHL`.
97
+ envPrefix: SOHL
98
+ ```
99
+
100
+ **Why one file and not two.** Two of the values this package needs —
101
+ `packageKind` and `foundryPackage` — are already declared for `content-build`. A
102
+ second config file would restate them, which is two places for one fact; that is
103
+ exactly what every consumer's `push-stage.mjs` did, hard-coding
104
+ `packageKind: "systems"` and `packageId: "sohl"` beside a configuration that
105
+ already said both.
106
+
107
+ `content-build` checks only that `packageBuild:` is a mapping and hands it back
108
+ frozen. Everything inside it is validated here, so neither package learns the
109
+ other's schema — they split by input, and the dependency runs one way.
110
+
111
+ **What is derived, not stated:**
112
+
113
+ | Field | Derived from |
114
+ | -------------------------- | -------------------------------------------------------------------- |
115
+ | the repository root | the configuration file's own location |
116
+ | `packageKind`, `packageId` | the shared configuration's top level |
117
+ | the release artifact | `packageKind` — a system ships `system.json`, a module `module.json` |
118
+
119
+ ## Command line
120
+
121
+ ```
122
+ npx package-build clean [--distclean]
123
+ npx package-build assets
124
+ npx package-build lang check
125
+ npx package-build release
126
+ npx package-build deploy <stage>
127
+ ```
128
+
129
+ Wrapped as npm scripts — SoHL spells them:
130
+
131
+ ```json
132
+ {
133
+ "clean": "package-build clean",
134
+ "distclean": "package-build clean --distclean",
135
+ "build:assets": "package-build assets",
136
+ "lint:lang": "package-build lang check",
137
+ "build:pack-release": "package-build release",
138
+ "push:qa": "package-build deploy qa"
139
+ }
140
+ ```
141
+
142
+ **Why the CLI exists.** This package was library-only, so every consuming
143
+ repository wrote a wrapper script per job — six of them in the SoHL repository,
144
+ 441 lines that between them contained no logic. `clean.mjs` was 47 lines that
145
+ computed a `repoRoot` from `import.meta.url`, read one flag, and made one call.
146
+ Every copy had drifted from its sibling in the other repositories, because
147
+ copies do: `clean.mjs` was 47 lines in one and 48 in another, `copy-assets.mjs`
148
+ 71 and 76.
149
+
150
+ It is the same shape the configuration had before it became data — not logic,
151
+ but the boilerplate a code file needs in order to state a literal. The literals
152
+ moved into configuration; the boilerplate lives in the CLI, once.
153
+
154
+ `--version` and `--help` answer in a directory with no configuration at all.
155
+ Running an actual command resolves it, and fails loudly when it is missing.
156
+
59
157
  ## Design
60
158
 
61
159
  **The rules are pure, and I/O is confined to functions named for it.** A rule
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
4
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
5
+ *
6
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
7
+ * You may copy, modify, and distribute it under the terms of that license.
8
+ *
9
+ * For full terms, see the LICENSE.md file in the project root or visit:
10
+ * https://www.gnu.org/licenses/gpl-3.0.html
11
+ *
12
+ * SPDX-License-Identifier: GPL-3.0-or-later
13
+ */
14
+
15
+ /**
16
+ * The `package-build` command line — clean, stage, check, package, deploy.
17
+ *
18
+ * **Why this exists.** This package was library-only, so every consuming
19
+ * repository wrote a wrapper script per job: six of them in the Song of Heroic
20
+ * Lands repository, 441 lines that between them contained no logic. `clean.mjs`
21
+ * was 47 lines that computed a `repoRoot` from `import.meta.url`, read one
22
+ * flag, and made one call. `push-stage.mjs` hard-coded `packageKind: "systems"`
23
+ * and `packageId: "sohl"` beside a configuration that already declared both.
24
+ * Each copy had drifted from its sibling in the other repositories, because
25
+ * copies do.
26
+ *
27
+ * It is the same shape the configuration had before it became data: not logic,
28
+ * but the boilerplate a code file needs in order to state a literal. So the
29
+ * literals move into `content-build.config.yaml`'s reserved `packageBuild:`
30
+ * section, and the boilerplate lives here, once.
31
+ *
32
+ * **Every side effect lives in this file.** argv parsing, the environment,
33
+ * writing to the filesystem, and the process exit code. The library modules
34
+ * stay import-safe, so a consuming repository's build — or a test — can call
35
+ * them without any of it happening.
36
+ *
37
+ * The side effects that need *configuration* live inside the command handlers,
38
+ * never at module scope, so `--version` and `--help` answer in a directory with
39
+ * no configuration at all. Running an actual command still resolves it, and
40
+ * still fails loudly when it is missing.
41
+ *
42
+ * Usage:
43
+ * npx package-build clean [--distclean]
44
+ * npx package-build assets
45
+ * npx package-build lang check
46
+ * npx package-build release
47
+ * npx package-build deploy <stage>
48
+ *
49
+ * In a consuming repository, wrapped as npm scripts — SoHL spells them:
50
+ * npm run clean // → … clean
51
+ * npm run build:assets // → … assets
52
+ * npm run lint:lang // → … lang check
53
+ * npm run build:pack-release // → … release
54
+ * npm run push:qa // → … deploy qa
55
+ */
56
+
57
+ import fs from "node:fs";
58
+ import path from "node:path";
59
+ import process from "node:process";
60
+ import { globSync } from "glob";
61
+ import yargs from "yargs";
62
+ import { hideBin } from "yargs/helpers";
63
+
64
+ import { loadPackageBuildConfig } from "../config.mjs";
65
+ import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
66
+ import { validateLangSource } from "../lang.mjs";
67
+ import { packRelease } from "../release.mjs";
68
+ import { deployStage } from "../deploy.mjs";
69
+
70
+ /**
71
+ * This package's own version, for `--version`.
72
+ *
73
+ * Read from this package's manifest rather than left to yargs, which defaults
74
+ * to the *nearest* `package.json` walking up from the working directory —
75
+ * inside a consuming repository that is the consumer's, so the CLI would report
76
+ * the consumer's version instead of the toolchain's.
77
+ *
78
+ * @returns {string} The `version` field of this package's manifest.
79
+ */
80
+ function ownVersion() {
81
+ return JSON.parse(
82
+ fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
83
+ ).version;
84
+ }
85
+
86
+ /**
87
+ * Report a failure the way a build should: one line, no stack, non-zero exit.
88
+ *
89
+ * @param {unknown} err - What went wrong.
90
+ * @returns {never}
91
+ */
92
+ function die(err) {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ console.error(`package-build: ${message}`);
95
+ process.exit(1);
96
+ }
97
+
98
+ /**
99
+ * Wrap a command handler so every failure is reported the same way.
100
+ *
101
+ * yargs' own `.fail()` sees a *synchronous* handler's throw but not an async
102
+ * one's rejection, so without this a `clean` failure printed one clean line and
103
+ * a `deploy` failure printed a stack trace. A build's diagnostics should not
104
+ * depend on whether the command it ran happened to await something.
105
+ *
106
+ * @param {(args: object) => unknown} run - The handler body.
107
+ * @returns {(args: object) => Promise<void>} The wrapped handler.
108
+ */
109
+ function handler(run) {
110
+ return async (args) => {
111
+ try {
112
+ await run(args);
113
+ } catch (err) {
114
+ die(err);
115
+ }
116
+ };
117
+ }
118
+
119
+ /**
120
+ * `clean` — remove this repository's build artifacts.
121
+ *
122
+ * The conventional artifact directories are the library's; a repository that
123
+ * generates more (a site's `content/`, `public/` and `resources/`) names them
124
+ * in `packageBuild.clean.extra` rather than reimplementing the walk, which is
125
+ * what every consumer's `clean.mjs` did.
126
+ *
127
+ * @returns {object} The yargs command module.
128
+ */
129
+ function cleanCommand() {
130
+ return {
131
+ command: "clean",
132
+ describe: "Remove build artifacts",
133
+ builder: (y) =>
134
+ y.option("distclean", {
135
+ type: "boolean",
136
+ default: false,
137
+ describe: "Also remove node_modules",
138
+ }),
139
+ handler: handler((args) => {
140
+ const config = loadPackageBuildConfig();
141
+ const removed = cleanBuildArtifacts(config.rootDir, {
142
+ includeNodeModules: args.distclean,
143
+ extra: config.cleanExtra,
144
+ });
145
+ for (const dir of removed) console.log(`Removed ${dir}`);
146
+ if (!removed.length) console.log("Nothing to clean.");
147
+ }),
148
+ };
149
+ }
150
+
151
+ /**
152
+ * `assets` — stage the repository's static files into the package root.
153
+ *
154
+ * The table is data (`packageBuild.assets`). A repository that has to *change*
155
+ * a file on the way — SoHL rewrites each SVG's hard-coded fill so icons follow
156
+ * the Foundry theme — names a module in `packageBuild.assetTransform`, whose
157
+ * `transform(sourcePath)` returns replacement text or `null` to copy verbatim.
158
+ * That is the one genuine piece of code in the job, and it stays the
159
+ * repository's.
160
+ *
161
+ * @returns {object} The yargs command module.
162
+ */
163
+ function assetsCommand() {
164
+ return {
165
+ command: "assets",
166
+ describe: "Stage static assets into the package root",
167
+ builder: (y) => y,
168
+ handler: handler(async () => {
169
+ const config = loadPackageBuildConfig();
170
+ if (!config.assets.length) {
171
+ console.log(
172
+ "package-build: no `packageBuild.assets` declared; nothing to stage.",
173
+ );
174
+ return;
175
+ }
176
+
177
+ let transform;
178
+ if (config.assetTransform) {
179
+ const module = await import(
180
+ `file://${config.assetTransform}`
181
+ ).catch((err) =>
182
+ die(
183
+ `cannot load \`packageBuild.assetTransform\` ` +
184
+ `(${config.assetTransform}): ${err.message}`,
185
+ ),
186
+ );
187
+ transform = module.transform;
188
+ if (typeof transform !== "function") {
189
+ die(
190
+ `\`packageBuild.assetTransform\` ` +
191
+ `(${config.assetTransform}) exports no \`transform\` ` +
192
+ `function. It must export ` +
193
+ `\`transform(sourcePath) -> string | null\`.`,
194
+ );
195
+ }
196
+ }
197
+
198
+ // `to:` is relative to the staged package root, so a
199
+ // repository's table reads `lang`, not `build/stage/lang`.
200
+ const entries = config.assets.map(({ from, to }) => [
201
+ from,
202
+ path.join(config.stageDir, to),
203
+ ]);
204
+ const { entries: count, files } = stageAssets(entries, {
205
+ cwd: config.rootDir,
206
+ transform,
207
+ });
208
+ console.log(
209
+ `✅ Static assets staged (${count} entries, ${files} files).`,
210
+ );
211
+ }),
212
+ };
213
+ }
214
+
215
+ /**
216
+ * `lang check` — verify every localization file survives `expandObject`.
217
+ *
218
+ * A dotted-prefix collision makes `foundry.utils.expandObject` throw, and
219
+ * Foundry then drops the whole translation file silently. The rule is the
220
+ * library's; the glob and any repository-specific guidance are data.
221
+ *
222
+ * @returns {object} The yargs command module.
223
+ */
224
+ function langCommand() {
225
+ return {
226
+ command: "lang <action>",
227
+ describe: "Localization checks",
228
+ builder: (y) =>
229
+ y.positional("action", {
230
+ choices: ["check"],
231
+ describe: "check: verify the files are expandObject-safe",
232
+ }),
233
+ handler: handler(() => {
234
+ const config = loadPackageBuildConfig();
235
+ const files = globSync(config.langSources, {
236
+ cwd: config.rootDir,
237
+ absolute: true,
238
+ });
239
+ if (!files.length) {
240
+ die(
241
+ `no localization files matched ` +
242
+ `\`${config.langSources}\` under ${config.rootDir}.`,
243
+ );
244
+ }
245
+
246
+ let total = 0;
247
+ for (const file of files.sort()) {
248
+ const relative = path.relative(config.rootDir, file);
249
+ for (const finding of validateLangSource(
250
+ fs.readFileSync(file, "utf8"),
251
+ )) {
252
+ total++;
253
+ // The diagnostics contract: the path starts the line, and a
254
+ // field is dropped rather than guessed.
255
+ const at = [relative, finding.line, finding.column]
256
+ .filter((part) => part !== undefined && part !== null)
257
+ .join(":");
258
+ console.error(
259
+ `${at}: ${finding.severity ?? "error"}: ${finding.message}`,
260
+ );
261
+ }
262
+ }
263
+
264
+ if (total) {
265
+ if (config.langHelp) console.error(`\n${config.langHelp}`);
266
+ process.exit(1);
267
+ }
268
+ console.log(
269
+ `package-build: ${files.length} localization file(s) are ` +
270
+ `expandObject-safe.`,
271
+ );
272
+ }),
273
+ };
274
+ }
275
+
276
+ /**
277
+ * `release` — zip the staged package for a GitHub release.
278
+ *
279
+ * The artifact name is derived from `packageKind`: Foundry installs a system
280
+ * from `system.json` and a module from `module.json`, so the kind already
281
+ * decides it and no repository states it.
282
+ *
283
+ * @returns {object} The yargs command module.
284
+ */
285
+ function releaseCommand() {
286
+ return {
287
+ command: "release",
288
+ describe: "Package the staged build for release",
289
+ builder: (y) => y,
290
+ handler: handler(async () => {
291
+ const config = loadPackageBuildConfig();
292
+ const { zip, version, bytes } = await packRelease({
293
+ artifact: config.artifact,
294
+ });
295
+ console.log(
296
+ `✅ Packaged ${version} for release: ` +
297
+ `${path.relative(config.rootDir, zip)} ` +
298
+ `(${(bytes / 1024 / 1024).toFixed(1)} MB)`,
299
+ );
300
+ }),
301
+ };
302
+ }
303
+
304
+ /**
305
+ * `deploy <stage>` — push the staged package to a Foundry data directory.
306
+ *
307
+ * `packageKind` and `packageId` come from the shared configuration, where they
308
+ * were already declared. Every consumer's `push-stage.mjs` hard-coded them a
309
+ * second time, which is two places for one fact and exactly the drift this
310
+ * command removes.
311
+ *
312
+ * @returns {object} The yargs command module.
313
+ */
314
+ function deployCommand() {
315
+ return {
316
+ command: "deploy <stage>",
317
+ describe: "Deploy the staged package to a stage",
318
+ builder: (y) =>
319
+ y.positional("stage", {
320
+ type: "string",
321
+ describe: "Target stage (e.g. dev, qa, prod, test)",
322
+ }),
323
+ handler: handler(async (args) => {
324
+ const config = loadPackageBuildConfig();
325
+
326
+ // Loaded here rather than at module scope: `--help` must answer in
327
+ // a repository that has no environment file at all.
328
+ const dotenv = await import("dotenv");
329
+ dotenv.config({
330
+ path: path.join(config.rootDir, ".env.local"),
331
+ quiet: true,
332
+ });
333
+ dotenv.config({
334
+ path: path.join(config.rootDir, ".env"),
335
+ quiet: true,
336
+ });
337
+
338
+ const { stage } = await deployStage({
339
+ stage: args.stage,
340
+ source: path.join(config.rootDir, config.stageDir),
341
+ packageKind: config.packageKind,
342
+ packageId: config.packageId,
343
+ prefix: config.envPrefix,
344
+ log: (message) => console.log(message),
345
+ });
346
+ console.log(`Deployed stage '${stage}' successfully.`);
347
+ }),
348
+ };
349
+ }
350
+
351
+ yargs(hideBin(process.argv))
352
+ .scriptName("package-build")
353
+ .command(cleanCommand())
354
+ .command(assetsCommand())
355
+ .command(langCommand())
356
+ .command(releaseCommand())
357
+ .command(deployCommand())
358
+ .demandCommand(1, "Name a command.")
359
+ .strict()
360
+ .version(ownVersion())
361
+ .help()
362
+ .alias("help", "h")
363
+ .fail((message, err) => die(err ?? message)).argv;
package/config.mjs ADDED
@@ -0,0 +1,318 @@
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
+ * The per-repository configuration this package reads.
16
+ *
17
+ * **One repository, one configuration file.** A repository already declares
18
+ * itself in `content-build.config.yaml`, and two of the values this package
19
+ * needs — `packageKind` and `foundryPackage` — are already in it. A second file
20
+ * would restate them, which is two places for one fact; that is precisely what
21
+ * every consumer's `push-stage.mjs` did, hard-coding `packageKind: "systems"`
22
+ * and `packageId: "sohl"` beside a config that already said both.
23
+ *
24
+ * So this package reads the *same* file, through content-build's loader, and
25
+ * takes its own settings from the reserved `packageBuild:` section. The two
26
+ * packages split by **input** — content-build reads the content tree, this one
27
+ * reads `lang/`, `styles/`, `src/`, the assets and the manifest template — and
28
+ * neither validates the other's keys. content-build checks that the section is
29
+ * a mapping and hands it back frozen; everything inside it is validated here.
30
+ *
31
+ * **The dependency runs one way.** This package depends on content-build;
32
+ * content-build must never depend on this one. It is the same direction the
33
+ * loader already implies, and keeping it means content-build stays usable by a
34
+ * repository that ships content and no Foundry package at all.
35
+ *
36
+ * ```yaml
37
+ * # content-build.config.yaml
38
+ * packageKind: systems # read from the top level, not restated below
39
+ * foundryPackage: sohl
40
+ *
41
+ * packageBuild:
42
+ * assets:
43
+ * - { from: lang, to: lang }
44
+ * - { from: assets/icons, to: assets/icons }
45
+ * assetTransform: ./utils/svg-theme.mjs
46
+ * stageDir: build/stage
47
+ * clean:
48
+ * extra: [site/content, site/public]
49
+ * lang:
50
+ * sources: lang/*.json
51
+ * deploy:
52
+ * envPrefix: SOHL
53
+ * ```
54
+ *
55
+ * @module
56
+ */
57
+
58
+ import path from "node:path";
59
+ import { loadPackConfig } from "@heroiclands/content-build/engine/pack-config";
60
+
61
+ /** Keys the reserved section may declare. */
62
+ const SECTION_KEYS = [
63
+ "stageDir",
64
+ "assets",
65
+ "assetTransform",
66
+ "clean",
67
+ "lang",
68
+ "deploy",
69
+ "release",
70
+ ];
71
+ const ASSET_KEYS = ["from", "to"];
72
+ const CLEAN_KEYS = ["extra"];
73
+ const LANG_KEYS = ["sources", "help"];
74
+ const DEPLOY_KEYS = ["envPrefix"];
75
+ const RELEASE_KEYS = ["artifact"];
76
+
77
+ /**
78
+ * The artifact name each package kind ships, so no repository states it.
79
+ *
80
+ * Foundry installs a system from `system.json` and a module from `module.json`;
81
+ * the kind already says which, so `pack-release.mjs` passing
82
+ * `{ artifact: "system" }` by hand was restating `packageKind`.
83
+ */
84
+ const ARTIFACT_OF_KIND = Object.freeze({
85
+ systems: "system",
86
+ modules: "module",
87
+ });
88
+
89
+ /**
90
+ * @param {string} where - Dotted path of the offending key.
91
+ * @param {string} problem - What is wrong with it.
92
+ * @returns {never}
93
+ */
94
+ function fail(where, problem) {
95
+ throw new TypeError(`package-build config: \`${where}\` ${problem}.`);
96
+ }
97
+
98
+ /**
99
+ * @param {unknown} value
100
+ * @returns {boolean} Whether it is a plain mapping.
101
+ */
102
+ function isMapping(value) {
103
+ return value !== null && typeof value === "object" && !Array.isArray(value);
104
+ }
105
+
106
+ /**
107
+ * @param {Record<string, unknown>} object - The mapping to check.
108
+ * @param {readonly string[]} allowed - The keys it may declare.
109
+ * @param {string} prefix - Dotted path prefix for the error.
110
+ */
111
+ function rejectUnknownKeys(object, allowed, prefix) {
112
+ for (const key of Object.keys(object)) {
113
+ if (!allowed.includes(key)) {
114
+ fail(
115
+ `${prefix}${key}`,
116
+ `is not a recognised key (expected one of: ${allowed.join(", ")})`,
117
+ );
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * @param {unknown} value
124
+ * @param {string} where
125
+ * @returns {string}
126
+ */
127
+ function requireNonEmptyString(value, where) {
128
+ if (typeof value !== "string" || value.trim() === "") {
129
+ fail(where, "must be a non-empty string");
130
+ }
131
+ return /** @type {string} */ (value);
132
+ }
133
+
134
+ /**
135
+ * One staging copy: a source path in the repository, and where it lands under
136
+ * the staged package root.
137
+ *
138
+ * @typedef {object} AssetSpec
139
+ * @property {string} from Source path, relative to the repository root.
140
+ * @property {string} to Destination, relative to the staged package root.
141
+ */
142
+
143
+ /**
144
+ * @param {unknown} value
145
+ * @param {number} index
146
+ * @returns {Readonly<AssetSpec>}
147
+ */
148
+ function normalizeAsset(value, index) {
149
+ const where = `packageBuild.assets[${index}]`;
150
+ if (!isMapping(value)) fail(where, "must be a mapping");
151
+ const asset = /** @type {Record<string, unknown>} */ (value);
152
+ rejectUnknownKeys(asset, ASSET_KEYS, `${where}.`);
153
+ return Object.freeze({
154
+ from: requireNonEmptyString(asset.from, `${where}.from`),
155
+ to: requireNonEmptyString(asset.to, `${where}.to`),
156
+ });
157
+ }
158
+
159
+ /**
160
+ * The resolved `packageBuild` section, every optional half filled in.
161
+ *
162
+ * @typedef {object} PackageBuildConfig
163
+ * @property {string} rootDir The repository root, from content-build.
164
+ * @property {string} packageKind `systems` or `modules`.
165
+ * @property {string} packageId The Foundry package id.
166
+ * @property {string} artifact Derived: `system` or `module`.
167
+ * @property {string} stageDir The staged package root, relative to
168
+ * `rootDir`. Every asset `to:` lands under it.
169
+ * @property {readonly Readonly<AssetSpec>[]} assets
170
+ * @property {string|null} assetTransform Module to load a `transform` from,
171
+ * resolved against `rootDir`. `null` when
172
+ * the repository stages assets verbatim.
173
+ * @property {readonly string[]} cleanExtra Directories to remove beyond the
174
+ * conventional build artifacts.
175
+ * @property {string} langSources Glob for the localization files to check.
176
+ * @property {string|null} langHelp Extra guidance printed after a failure.
177
+ * @property {string} envPrefix Prefix of the deploy environment variables.
178
+ */
179
+
180
+ /**
181
+ * Resolve a package-build configuration from an already-loaded shared one.
182
+ *
183
+ * Separate from {@link loadPackageBuildConfig} because this half is pure: it
184
+ * reads no file and touches no environment, so the validation rules can be
185
+ * described directly by a test instead of through a fixture repository on
186
+ * disk. {@link loadPackageBuildConfig} is the same function with the loading
187
+ * put back.
188
+ *
189
+ * @param {object} shared - The resolved content-build configuration.
190
+ * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
191
+ * @throws {TypeError} When the reserved section declares something malformed.
192
+ */
193
+ export function resolvePackageBuildConfig(shared) {
194
+ const section = /** @type {Record<string, unknown>} */ (
195
+ shared.packageBuild ?? {}
196
+ );
197
+ rejectUnknownKeys(section, SECTION_KEYS, "packageBuild.");
198
+
199
+ if (section.assets !== undefined && !Array.isArray(section.assets)) {
200
+ fail("packageBuild.assets", "must be a list");
201
+ }
202
+ const assets = (section.assets ?? []).map(normalizeAsset);
203
+
204
+ const clean = section.clean ?? {};
205
+ if (!isMapping(clean)) fail("packageBuild.clean", "must be a mapping");
206
+ rejectUnknownKeys(
207
+ /** @type {Record<string, unknown>} */ (clean),
208
+ CLEAN_KEYS,
209
+ "packageBuild.clean.",
210
+ );
211
+ const extra = /** @type {Record<string, unknown>} */ (clean).extra ?? [];
212
+ if (!Array.isArray(extra)) {
213
+ fail("packageBuild.clean.extra", "must be a list");
214
+ }
215
+ const cleanExtra = extra.map((dir, i) =>
216
+ requireNonEmptyString(dir, `packageBuild.clean.extra[${i}]`),
217
+ );
218
+
219
+ const lang = section.lang ?? {};
220
+ if (!isMapping(lang)) fail("packageBuild.lang", "must be a mapping");
221
+ rejectUnknownKeys(
222
+ /** @type {Record<string, unknown>} */ (lang),
223
+ LANG_KEYS,
224
+ "packageBuild.lang.",
225
+ );
226
+ const langInput = /** @type {Record<string, unknown>} */ (lang);
227
+
228
+ const deploy = section.deploy ?? {};
229
+ if (!isMapping(deploy)) fail("packageBuild.deploy", "must be a mapping");
230
+ rejectUnknownKeys(
231
+ /** @type {Record<string, unknown>} */ (deploy),
232
+ DEPLOY_KEYS,
233
+ "packageBuild.deploy.",
234
+ );
235
+ const deployInput = /** @type {Record<string, unknown>} */ (deploy);
236
+
237
+ const release = section.release ?? {};
238
+ if (!isMapping(release)) fail("packageBuild.release", "must be a mapping");
239
+ rejectUnknownKeys(
240
+ /** @type {Record<string, unknown>} */ (release),
241
+ RELEASE_KEYS,
242
+ "packageBuild.release.",
243
+ );
244
+ const releaseInput = /** @type {Record<string, unknown>} */ (release);
245
+
246
+ return Object.freeze({
247
+ rootDir: shared.rootDir,
248
+ // Where the package is assembled before it is zipped or deployed. Every
249
+ // asset destination is relative to it, so a repository's table says
250
+ // `lang`, not `build/stage/lang` — the latter is what each consumer's
251
+ // `copy-assets.mjs` spelled out on every row.
252
+ stageDir:
253
+ section.stageDir === undefined ?
254
+ "build/stage"
255
+ : requireNonEmptyString(
256
+ section.stageDir,
257
+ "packageBuild.stageDir",
258
+ ),
259
+ packageKind: shared.packageKind,
260
+ packageId: shared.foundryPackage,
261
+ // Derived from the kind, which already decides it. Stating it was one
262
+ // more literal every consumer's release script carried.
263
+ artifact:
264
+ releaseInput.artifact === undefined ?
265
+ ARTIFACT_OF_KIND[
266
+ /** @type {"systems"|"modules"} */ (shared.packageKind)
267
+ ]
268
+ : requireNonEmptyString(
269
+ releaseInput.artifact,
270
+ "packageBuild.release.artifact",
271
+ ),
272
+ assets: Object.freeze(assets),
273
+ assetTransform:
274
+ section.assetTransform === undefined ?
275
+ null
276
+ : path.resolve(
277
+ shared.rootDir,
278
+ requireNonEmptyString(
279
+ section.assetTransform,
280
+ "packageBuild.assetTransform",
281
+ ),
282
+ ),
283
+ cleanExtra: Object.freeze(cleanExtra),
284
+ langSources:
285
+ langInput.sources === undefined ?
286
+ "lang/*.json"
287
+ : requireNonEmptyString(
288
+ langInput.sources,
289
+ "packageBuild.lang.sources",
290
+ ),
291
+ langHelp:
292
+ langInput.help === undefined ?
293
+ null
294
+ : requireNonEmptyString(langInput.help, "packageBuild.lang.help"),
295
+ envPrefix:
296
+ deployInput.envPrefix === undefined ?
297
+ "SOHL"
298
+ : requireNonEmptyString(
299
+ deployInput.envPrefix,
300
+ "packageBuild.deploy.envPrefix",
301
+ ),
302
+ });
303
+ }
304
+
305
+ /**
306
+ * The repository's resolved package-build configuration.
307
+ *
308
+ * Read on call rather than at import, exactly as content-build resolves its
309
+ * own: importing a module of this package must not require a configuration to
310
+ * exist anywhere above it.
311
+ *
312
+ * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
313
+ * @throws {TypeError} When there is no configuration, or the reserved section
314
+ * declares something malformed.
315
+ */
316
+ export function loadPackageBuildConfig() {
317
+ return resolvePackageBuildConfig(loadPackConfig());
318
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -39,7 +39,11 @@
39
39
  "types": "./types/text.d.mts",
40
40
  "import": "./text.mjs"
41
41
  },
42
- "./package.json": "./package.json"
42
+ "./package.json": "./package.json",
43
+ "./config": {
44
+ "types": "./types/config.d.mts",
45
+ "import": "./config.mjs"
46
+ }
43
47
  },
44
48
  "files": [
45
49
  "bundle.mjs",
@@ -51,7 +55,9 @@
51
55
  "stage.mjs",
52
56
  "text.mjs",
53
57
  "types",
54
- "README.md"
58
+ "README.md",
59
+ "bin",
60
+ "config.mjs"
55
61
  ],
56
62
  "scripts": {
57
63
  "test": "vitest run",
@@ -63,9 +69,13 @@
63
69
  "prepare": "git config core.hooksPath .githooks || true"
64
70
  },
65
71
  "dependencies": {
72
+ "@heroiclands/content-build": "^0.15.0",
66
73
  "acorn": "^8.18.0",
67
74
  "archiver": "^8.0.0",
68
- "ssh2-sftp-client": "^12.1.1"
75
+ "dotenv": "^17.2.3",
76
+ "glob": "^11.0.3",
77
+ "ssh2-sftp-client": "^12.1.1",
78
+ "yargs": "^18.1.0"
69
79
  },
70
80
  "devDependencies": {
71
81
  "@types/node": "^26.2.0",
@@ -95,5 +105,8 @@
95
105
  },
96
106
  "bugs": {
97
107
  "url": "https://github.com/HeroicLands/package-build/issues"
108
+ },
109
+ "bin": {
110
+ "package-build": "./bin/package-build.mjs"
98
111
  }
99
112
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The resolved `packageBuild` section, every optional half filled in.
3
+ *
4
+ * @typedef {object} PackageBuildConfig
5
+ * @property {string} rootDir The repository root, from content-build.
6
+ * @property {string} packageKind `systems` or `modules`.
7
+ * @property {string} packageId The Foundry package id.
8
+ * @property {string} artifact Derived: `system` or `module`.
9
+ * @property {string} stageDir The staged package root, relative to
10
+ * `rootDir`. Every asset `to:` lands under it.
11
+ * @property {readonly Readonly<AssetSpec>[]} assets
12
+ * @property {string|null} assetTransform Module to load a `transform` from,
13
+ * resolved against `rootDir`. `null` when
14
+ * the repository stages assets verbatim.
15
+ * @property {readonly string[]} cleanExtra Directories to remove beyond the
16
+ * conventional build artifacts.
17
+ * @property {string} langSources Glob for the localization files to check.
18
+ * @property {string|null} langHelp Extra guidance printed after a failure.
19
+ * @property {string} envPrefix Prefix of the deploy environment variables.
20
+ */
21
+ /**
22
+ * Resolve a package-build configuration from an already-loaded shared one.
23
+ *
24
+ * Separate from {@link loadPackageBuildConfig} because this half is pure: it
25
+ * reads no file and touches no environment, so the validation rules can be
26
+ * described directly by a test instead of through a fixture repository on
27
+ * disk. {@link loadPackageBuildConfig} is the same function with the loading
28
+ * put back.
29
+ *
30
+ * @param {object} shared - The resolved content-build configuration.
31
+ * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
32
+ * @throws {TypeError} When the reserved section declares something malformed.
33
+ */
34
+ export function resolvePackageBuildConfig(shared: object): Readonly<PackageBuildConfig>;
35
+ /**
36
+ * The repository's resolved package-build configuration.
37
+ *
38
+ * Read on call rather than at import, exactly as content-build resolves its
39
+ * own: importing a module of this package must not require a configuration to
40
+ * exist anywhere above it.
41
+ *
42
+ * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
43
+ * @throws {TypeError} When there is no configuration, or the reserved section
44
+ * declares something malformed.
45
+ */
46
+ export function loadPackageBuildConfig(): Readonly<PackageBuildConfig>;
47
+ /**
48
+ * One staging copy: a source path in the repository, and where it lands under
49
+ * the staged package root.
50
+ */
51
+ export type AssetSpec = {
52
+ /**
53
+ * Source path, relative to the repository root.
54
+ */
55
+ from: string;
56
+ /**
57
+ * Destination, relative to the staged package root.
58
+ */
59
+ to: string;
60
+ };
61
+ /**
62
+ * The resolved `packageBuild` section, every optional half filled in.
63
+ */
64
+ export type PackageBuildConfig = {
65
+ /**
66
+ * The repository root, from content-build.
67
+ */
68
+ rootDir: string;
69
+ /**
70
+ * `systems` or `modules`.
71
+ */
72
+ packageKind: string;
73
+ /**
74
+ * The Foundry package id.
75
+ */
76
+ packageId: string;
77
+ /**
78
+ * Derived: `system` or `module`.
79
+ */
80
+ artifact: string;
81
+ /**
82
+ * The staged package root, relative to
83
+ * `rootDir`. Every asset `to:` lands under it.
84
+ */
85
+ stageDir: string;
86
+ assets: readonly Readonly<AssetSpec>[];
87
+ /**
88
+ * Module to load a `transform` from,
89
+ * resolved against `rootDir`. `null` when
90
+ * the repository stages assets verbatim.
91
+ */
92
+ assetTransform: string | null;
93
+ /**
94
+ * Directories to remove beyond the
95
+ * conventional build artifacts.
96
+ */
97
+ cleanExtra: readonly string[];
98
+ /**
99
+ * Glob for the localization files to check.
100
+ */
101
+ langSources: string;
102
+ /**
103
+ * Extra guidance printed after a failure.
104
+ */
105
+ langHelp: string | null;
106
+ /**
107
+ * Prefix of the deploy environment variables.
108
+ */
109
+ envPrefix: string;
110
+ };