@heroiclands/package-build 0.1.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/LICENSE.md +25 -0
- package/README.md +105 -0
- package/bundle.mjs +250 -0
- package/deploy.mjs +334 -0
- package/index.mjs +61 -0
- package/lang.mjs +197 -0
- package/manifest.mjs +223 -0
- package/package.json +99 -0
- package/release.mjs +113 -0
- package/stage.mjs +177 -0
- package/text.mjs +74 -0
- package/types/bundle.d.mts +60 -0
- package/types/deploy.d.mts +148 -0
- package/types/index.d.mts +7 -0
- package/types/lang.d.mts +44 -0
- package/types/manifest.d.mts +122 -0
- package/types/release.d.mts +31 -0
- package/types/stage.d.mts +84 -0
- package/types/text.d.mts +51 -0
package/manifest.mjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
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
|
+
* Building the Foundry package manifest — `system.json` or `module.json`.
|
|
16
|
+
*
|
|
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.
|
|
29
|
+
*
|
|
30
|
+
* **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.
|
|
35
|
+
*
|
|
36
|
+
* The rules are pure functions over data. I/O is confined to
|
|
37
|
+
* {@link writeFoundryManifest}, which is the only export that touches disk.
|
|
38
|
+
*
|
|
39
|
+
* @module
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import fs from "node:fs/promises";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The two package kinds Foundry defines, as the artifact name each one's
|
|
47
|
+
* manifest and release archive are called.
|
|
48
|
+
*
|
|
49
|
+
* A system ships `system.json` / `system.zip`; a module ships `module.json` /
|
|
50
|
+
* `module.zip`. Foundry fetches those exact names, so the pair is not a naming
|
|
51
|
+
* convention this project is free to choose.
|
|
52
|
+
*/
|
|
53
|
+
export const ARTIFACTS = Object.freeze(["system", "module"]);
|
|
54
|
+
|
|
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
|
+
/**
|
|
84
|
+
* The repository's web address, from whatever spelling `package.json` carries.
|
|
85
|
+
*
|
|
86
|
+
* npm accepts several: a plain `https://` URL, the `git+https://….git` form npm
|
|
87
|
+
* itself writes, and a trailing slash either way. Foundry fetches
|
|
88
|
+
* `<url>/releases/latest/download/<artifact>.json` literally, so a `git+` prefix
|
|
89
|
+
* or a `.git` suffix left in place yields a 404 on every update check — with no
|
|
90
|
+
* error anywhere, because nothing fetches that URL until a user's Foundry does.
|
|
91
|
+
* `sohl-kethira-basic` declares the `git+….git` form today.
|
|
92
|
+
*
|
|
93
|
+
* @param {string|{url?: string}} repository - `package.json`'s `repository`
|
|
94
|
+
* field, in either object or shorthand-string form.
|
|
95
|
+
* @returns {string} The normalised `https://` URL, with no trailing slash.
|
|
96
|
+
* @throws {TypeError} When no URL can be read. A manifest with no addresses is
|
|
97
|
+
* worse than a missing one: Foundry installs it and never offers an update.
|
|
98
|
+
*/
|
|
99
|
+
export function normalizeRepoUrl(repository) {
|
|
100
|
+
const raw =
|
|
101
|
+
typeof repository === "string" ? repository : (repository?.url ?? "");
|
|
102
|
+
const url = String(raw)
|
|
103
|
+
.trim()
|
|
104
|
+
.replace(/^git\+/, "")
|
|
105
|
+
.replace(/\.git$/, "")
|
|
106
|
+
.replace(/\/+$/, "");
|
|
107
|
+
if (!url) {
|
|
108
|
+
throw new TypeError(
|
|
109
|
+
"package.json declares no `repository.url`, so the manifest has no " +
|
|
110
|
+
"release addresses to advertise. Add it.",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return url;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The four addresses a Foundry manifest advertises.
|
|
118
|
+
*
|
|
119
|
+
* `manifest` deliberately points at **`releases/latest`** rather than at this
|
|
120
|
+
* version: it is the URL an *installed* package re-fetches to discover that a
|
|
121
|
+
* newer one exists, so pinning it to the version being built would freeze every
|
|
122
|
+
* install at that release forever. `download` points at this exact version,
|
|
123
|
+
* because that is the archive this manifest describes.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} opts
|
|
126
|
+
* @param {string} opts.repoUrl - Normalised repository URL.
|
|
127
|
+
* @param {string} opts.version - The version being built.
|
|
128
|
+
* @param {"system"|"module"} opts.artifact - Which artifact is shipped.
|
|
129
|
+
* @returns {{url: string, bugs: string, manifest: string, download: string}}
|
|
130
|
+
*/
|
|
131
|
+
export function releaseUrls({ repoUrl, version, artifact }) {
|
|
132
|
+
return {
|
|
133
|
+
url: repoUrl,
|
|
134
|
+
bugs: `${repoUrl}/issues`,
|
|
135
|
+
manifest: `${repoUrl}/releases/latest/download/${artifact}.json`,
|
|
136
|
+
download: `${repoUrl}/releases/download/v${version}/${artifact}.zip`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Stamp a manifest template with the facts that must not be transcribed.
|
|
142
|
+
*
|
|
143
|
+
* Pure: the template is not mutated, and the result is a new object.
|
|
144
|
+
*
|
|
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.
|
|
150
|
+
*
|
|
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.
|
|
158
|
+
*/
|
|
159
|
+
export function stampManifest(template, { version, repoUrl, artifact, flags }) {
|
|
160
|
+
const stamped = {
|
|
161
|
+
...template,
|
|
162
|
+
version,
|
|
163
|
+
...releaseUrls({ repoUrl, version, artifact }),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (flags && Object.keys(flags).length) {
|
|
167
|
+
stamped.flags = { ...(template.flags ?? {}) };
|
|
168
|
+
for (const [namespace, values] of Object.entries(flags)) {
|
|
169
|
+
stamped.flags[namespace] = {
|
|
170
|
+
...(template.flags?.[namespace] ?? {}),
|
|
171
|
+
...values,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return stamped;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read a manifest template, stamp it, and write the result into the stage.
|
|
181
|
+
*
|
|
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.
|
|
196
|
+
*/
|
|
197
|
+
export async function writeFoundryManifest({
|
|
198
|
+
templatePath,
|
|
199
|
+
packageJson,
|
|
200
|
+
outDir,
|
|
201
|
+
artifact = undefined,
|
|
202
|
+
flags = undefined,
|
|
203
|
+
}) {
|
|
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
|
+
|
|
213
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
214
|
+
const outPath = path.join(outDir, `${kind}.json`);
|
|
215
|
+
// Trailing newline: the file is committed to a release archive and read by
|
|
216
|
+
// humans as often as by Foundry.
|
|
217
|
+
await fs.writeFile(
|
|
218
|
+
outPath,
|
|
219
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
220
|
+
"utf8",
|
|
221
|
+
);
|
|
222
|
+
return { path: outPath, manifest };
|
|
223
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@heroiclands/package-build",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — manifest, localization, staging, bundle, release and deployment.",
|
|
5
|
+
"license": "GPL-3.0-or-later",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./index.mjs",
|
|
8
|
+
"types": "./types/index.d.mts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./types/index.d.mts",
|
|
12
|
+
"import": "./index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"./bundle": {
|
|
15
|
+
"types": "./types/bundle.d.mts",
|
|
16
|
+
"import": "./bundle.mjs"
|
|
17
|
+
},
|
|
18
|
+
"./deploy": {
|
|
19
|
+
"types": "./types/deploy.d.mts",
|
|
20
|
+
"import": "./deploy.mjs"
|
|
21
|
+
},
|
|
22
|
+
"./lang": {
|
|
23
|
+
"types": "./types/lang.d.mts",
|
|
24
|
+
"import": "./lang.mjs"
|
|
25
|
+
},
|
|
26
|
+
"./manifest": {
|
|
27
|
+
"types": "./types/manifest.d.mts",
|
|
28
|
+
"import": "./manifest.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./release": {
|
|
31
|
+
"types": "./types/release.d.mts",
|
|
32
|
+
"import": "./release.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./stage": {
|
|
35
|
+
"types": "./types/stage.d.mts",
|
|
36
|
+
"import": "./stage.mjs"
|
|
37
|
+
},
|
|
38
|
+
"./text": {
|
|
39
|
+
"types": "./types/text.d.mts",
|
|
40
|
+
"import": "./text.mjs"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"bundle.mjs",
|
|
46
|
+
"deploy.mjs",
|
|
47
|
+
"index.mjs",
|
|
48
|
+
"lang.mjs",
|
|
49
|
+
"manifest.mjs",
|
|
50
|
+
"release.mjs",
|
|
51
|
+
"stage.mjs",
|
|
52
|
+
"text.mjs",
|
|
53
|
+
"types",
|
|
54
|
+
"README.md"
|
|
55
|
+
],
|
|
56
|
+
"scripts": {
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:watch": "vitest",
|
|
59
|
+
"prepack": "npm run build:types",
|
|
60
|
+
"build:types": "tsc -p tsconfig.dts.json",
|
|
61
|
+
"format": "prettier --write .",
|
|
62
|
+
"format:check": "prettier --check .",
|
|
63
|
+
"prepare": "git config core.hooksPath .githooks || true"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"acorn": "^8.18.0",
|
|
67
|
+
"archiver": "^8.0.0",
|
|
68
|
+
"ssh2-sftp-client": "^12.1.1"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@types/node": "^26.2.0",
|
|
72
|
+
"prettier": "^3.9.6",
|
|
73
|
+
"typescript": "^6.0.3",
|
|
74
|
+
"vitest": "^4.1.10"
|
|
75
|
+
},
|
|
76
|
+
"engines": {
|
|
77
|
+
"node": ">=24.0.0"
|
|
78
|
+
},
|
|
79
|
+
"publishConfig": {
|
|
80
|
+
"access": "public",
|
|
81
|
+
"provenance": true
|
|
82
|
+
},
|
|
83
|
+
"keywords": [
|
|
84
|
+
"foundry-vtt",
|
|
85
|
+
"foundryvtt",
|
|
86
|
+
"heroiclands",
|
|
87
|
+
"sohl",
|
|
88
|
+
"song-of-heroic-lands",
|
|
89
|
+
"build"
|
|
90
|
+
],
|
|
91
|
+
"homepage": "https://heroiclands.org",
|
|
92
|
+
"repository": {
|
|
93
|
+
"type": "git",
|
|
94
|
+
"url": "git+https://github.com/HeroicLands/package-build.git"
|
|
95
|
+
},
|
|
96
|
+
"bugs": {
|
|
97
|
+
"url": "https://github.com/HeroicLands/package-build/issues"
|
|
98
|
+
}
|
|
99
|
+
}
|
package/release.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
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 release archive — the two files a Foundry package's GitHub Release
|
|
16
|
+
* carries.
|
|
17
|
+
*
|
|
18
|
+
* Foundry installs a package by fetching the `download` URL its manifest
|
|
19
|
+
* advertises, so a release publishes exactly two assets: `<artifact>.zip`, the
|
|
20
|
+
* whole staged tree, and `<artifact>.json` beside it, which is what an already
|
|
21
|
+
* installed package re-fetches to notice a new version. Both names are fixed by
|
|
22
|
+
* what the manifest says, not chosen here — see `manifest.mjs`.
|
|
23
|
+
*
|
|
24
|
+
* Kept apart from `stage.mjs` because this is the only part of assembling a
|
|
25
|
+
* package that needs a dependency. A repository that never cuts a release from
|
|
26
|
+
* a local build imports the staging half and pays nothing for this one.
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import fsp from "node:fs/promises";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
|
|
35
|
+
// archiver 8 is pure ESM and exports **classes**, with no default export. The
|
|
36
|
+
// old `import archiver from "archiver"` factory call throws at import —
|
|
37
|
+
// `does not provide an export named 'default'` — which is how this repository's
|
|
38
|
+
// release job came to fail before a single byte was written (#1683).
|
|
39
|
+
import { ZipArchive } from "archiver";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Zip the staged tree and place the manifest beside the archive.
|
|
43
|
+
*
|
|
44
|
+
* **Waits for the output stream to close, not merely for the archive to
|
|
45
|
+
* finalize.** `finalize()` resolves once archiver has finished *appending*
|
|
46
|
+
* entries, which is before the bytes have necessarily reached disk; returning
|
|
47
|
+
* there can hand a later step — an upload, a checksum — a truncated file. The
|
|
48
|
+
* failure is timing-dependent, so it survives every run that happens to be
|
|
49
|
+
* fast enough, which is what makes it worth being explicit about.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} [opts]
|
|
52
|
+
* @param {string} [opts.stageDir] - The staged package tree.
|
|
53
|
+
* @param {string} [opts.outDir] - Where the release assets are written.
|
|
54
|
+
* @param {"system"|"module"} [opts.artifact] - Which artifact is shipped.
|
|
55
|
+
* Determines both asset names.
|
|
56
|
+
* @returns {Promise<{zip: string, manifest: string, bytes: number,
|
|
57
|
+
* version: string}>} The two paths written, the archive's size, and the
|
|
58
|
+
* version the manifest declares.
|
|
59
|
+
* @throws {Error} When the stage has no manifest — there is nothing to release,
|
|
60
|
+
* and an archive without one installs as nothing.
|
|
61
|
+
*/
|
|
62
|
+
export async function packRelease({
|
|
63
|
+
stageDir = "build/stage",
|
|
64
|
+
outDir = "build/dist",
|
|
65
|
+
artifact = "system",
|
|
66
|
+
} = {}) {
|
|
67
|
+
const stage = path.resolve(stageDir);
|
|
68
|
+
const out = path.resolve(outDir);
|
|
69
|
+
const manifestName = `${artifact}.json`;
|
|
70
|
+
const stagedManifest = path.join(stage, manifestName);
|
|
71
|
+
|
|
72
|
+
if (!fs.existsSync(stagedManifest)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`${stagedManifest} does not exist, so there is nothing to release. ` +
|
|
75
|
+
`Build the package first.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const manifest = JSON.parse(await fsp.readFile(stagedManifest, "utf8"));
|
|
80
|
+
await fsp.mkdir(out, { recursive: true });
|
|
81
|
+
|
|
82
|
+
const zipPath = path.join(out, `${artifact}.zip`);
|
|
83
|
+
const output = fs.createWriteStream(zipPath);
|
|
84
|
+
const archive = new ZipArchive({ zlib: { level: 9 } });
|
|
85
|
+
|
|
86
|
+
// Settled before anything is appended, so an error raised during the walk
|
|
87
|
+
// rejects rather than leaving the await below hanging forever.
|
|
88
|
+
const closed = new Promise((resolve, reject) => {
|
|
89
|
+
output.on("close", resolve);
|
|
90
|
+
output.on("error", reject);
|
|
91
|
+
archive.on("error", reject);
|
|
92
|
+
// A warning archiver can recover from (a vanished file, say) still
|
|
93
|
+
// means the archive is not the tree that was asked for.
|
|
94
|
+
archive.on("warning", reject);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
archive.pipe(output);
|
|
98
|
+
// `false` — no top-level directory inside the zip. Foundry unpacks the
|
|
99
|
+
// archive *into* the package directory, so an extra level would nest the
|
|
100
|
+
// manifest one deeper than it looks for it.
|
|
101
|
+
archive.directory(stage, false);
|
|
102
|
+
await archive.finalize();
|
|
103
|
+
await closed;
|
|
104
|
+
|
|
105
|
+
await fsp.copyFile(stagedManifest, path.join(out, manifestName));
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
zip: zipPath,
|
|
109
|
+
manifest: path.join(out, manifestName),
|
|
110
|
+
bytes: archive.pointer(),
|
|
111
|
+
version: manifest.version,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/stage.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
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 build **stage** — assembling the tree that becomes a Foundry package, and
|
|
16
|
+
* clearing it away again.
|
|
17
|
+
*
|
|
18
|
+
* A Foundry package is a directory: a manifest, some assets, compiled packs,
|
|
19
|
+
* and (if it ships code) a bundle. Every HeroicLands repository assembles that
|
|
20
|
+
* directory the same way and then either deploys it or zips it, so the copying,
|
|
21
|
+
* the cleaning and the archiving are one implementation with a per-repository
|
|
22
|
+
* *list*, not per-repository code.
|
|
23
|
+
*
|
|
24
|
+
* They were two implementations. This module takes the better half of each:
|
|
25
|
+
*
|
|
26
|
+
* - the **transform hook** from the system repository, which themes bundled SVG
|
|
27
|
+
* icons for light and dark mode as they are staged;
|
|
28
|
+
* - the **missing-source guard** from `sohl-thalorna`, which fails loudly on a
|
|
29
|
+
* listed path that does not exist. Without it a mistyped or moved source is a
|
|
30
|
+
* silent omission — a module that ships with no `lang/` and no warning, which
|
|
31
|
+
* is precisely the failure this project keeps finding in its own satellites.
|
|
32
|
+
*
|
|
33
|
+
* The rules are pure functions over data; the functions that touch disk are
|
|
34
|
+
* named for the effect they have.
|
|
35
|
+
*
|
|
36
|
+
* @module
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import fs from "node:fs";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Directories every HeroicLands repository regenerates and none commits.
|
|
44
|
+
*
|
|
45
|
+
* A repository adds its own — `sohl-thalorna` also clears the Hugo output
|
|
46
|
+
* beneath `site/` — but these four are common to all of them because they come
|
|
47
|
+
* from the shared toolchain rather than from any one package's layout.
|
|
48
|
+
*/
|
|
49
|
+
export const BUILD_ARTIFACT_DIRS = Object.freeze([
|
|
50
|
+
"build",
|
|
51
|
+
".vite",
|
|
52
|
+
".vitepress",
|
|
53
|
+
".rollup.cache",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A source that does not exist, described for a human.
|
|
58
|
+
*
|
|
59
|
+
* Pure, and separate from the copying, so the whole list is reported at once.
|
|
60
|
+
* Discovering missing sources one exception at a time means one fix, one
|
|
61
|
+
* rebuild, one more exception.
|
|
62
|
+
*
|
|
63
|
+
* @param {ReadonlyArray<readonly [string, string]>} entries - `[source, dest]`
|
|
64
|
+
* pairs, sources relative to `cwd` or absolute.
|
|
65
|
+
* @param {string} [cwd] - Resolved against this. Defaults to the process cwd.
|
|
66
|
+
* @returns {string[]} Every source that is absent, in the order listed.
|
|
67
|
+
*/
|
|
68
|
+
export function missingSources(entries, cwd = process.cwd()) {
|
|
69
|
+
return entries
|
|
70
|
+
.map(([src]) => src)
|
|
71
|
+
.filter((src) => !fs.existsSync(path.resolve(cwd, src)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Recursively copy `src` to `dest`.
|
|
76
|
+
*
|
|
77
|
+
* `transform(sourcePath)` may return a string to write **instead of** a byte
|
|
78
|
+
* copy; returning `null` or `undefined` falls back to copying the bytes. That
|
|
79
|
+
* is what lets a repository theme its icons, rewrite a config, or stamp a file
|
|
80
|
+
* as it stages it, without this function knowing anything about why.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} src - Source file or directory.
|
|
83
|
+
* @param {string} dest - Destination path.
|
|
84
|
+
* @param {object} [opts]
|
|
85
|
+
* @param {(sourcePath: string) => string|null|undefined} [opts.transform] -
|
|
86
|
+
* Per-file transform.
|
|
87
|
+
* @returns {number} How many files were written.
|
|
88
|
+
*/
|
|
89
|
+
export function copyTree(src, dest, { transform } = {}) {
|
|
90
|
+
if (fs.statSync(src).isDirectory()) {
|
|
91
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
92
|
+
let written = 0;
|
|
93
|
+
for (const entry of fs.readdirSync(src)) {
|
|
94
|
+
written += copyTree(path.join(src, entry), path.join(dest, entry), {
|
|
95
|
+
transform,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return written;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
102
|
+
const transformed = transform ? transform(src) : null;
|
|
103
|
+
if (transformed != null) fs.writeFileSync(dest, transformed);
|
|
104
|
+
else fs.copyFileSync(src, dest);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Copy every listed source into the stage, refusing to start if any is absent.
|
|
110
|
+
*
|
|
111
|
+
* **The guard is the point.** A listed path that does not exist is an error,
|
|
112
|
+
* not a silent skip: a missing `lang/` ships a package with no localization and
|
|
113
|
+
* nothing said so, and a missing `templates/` ships one whose every sheet fails
|
|
114
|
+
* to render. Both are indistinguishable from a successful build in the log.
|
|
115
|
+
*
|
|
116
|
+
* The check runs over the whole list *before* anything is copied, so a bad list
|
|
117
|
+
* leaves no half-populated stage behind.
|
|
118
|
+
*
|
|
119
|
+
* @param {ReadonlyArray<readonly [string, string]>} entries - `[source, dest]`
|
|
120
|
+
* pairs.
|
|
121
|
+
* @param {object} [opts]
|
|
122
|
+
* @param {string} [opts.cwd] - Sources and destinations resolve against this.
|
|
123
|
+
* @param {(sourcePath: string) => string|null|undefined} [opts.transform] -
|
|
124
|
+
* Per-file transform, applied to every entry.
|
|
125
|
+
* @returns {{entries: number, files: number}} What was staged.
|
|
126
|
+
* @throws {Error} When any source is missing, naming all of them.
|
|
127
|
+
*/
|
|
128
|
+
export function stageAssets(entries, { cwd = process.cwd(), transform } = {}) {
|
|
129
|
+
const missing = missingSources(entries, cwd);
|
|
130
|
+
if (missing.length) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Cannot stage assets — these paths do not exist:\n` +
|
|
133
|
+
missing.map((p) => ` ${p}`).join("\n"),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let files = 0;
|
|
138
|
+
for (const [src, dest] of entries) {
|
|
139
|
+
files += copyTree(path.resolve(cwd, src), path.resolve(cwd, dest), {
|
|
140
|
+
transform,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return { entries: entries.length, files };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Remove the build artefacts a repository regenerates.
|
|
148
|
+
*
|
|
149
|
+
* A directory that is already gone is not an error — the command has to be safe
|
|
150
|
+
* to run repeatedly, and "clean when already clean" is the ordinary case.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} root - Repository root; every directory resolves against it.
|
|
153
|
+
* @param {object} [opts]
|
|
154
|
+
* @param {readonly string[]} [opts.extra] - Directories beyond
|
|
155
|
+
* {@link BUILD_ARTIFACT_DIRS} that this repository also regenerates.
|
|
156
|
+
* @param {boolean} [opts.includeNodeModules] - Also remove `node_modules`, the
|
|
157
|
+
* `distclean` case.
|
|
158
|
+
* @returns {string[]} The directories removed, as listed.
|
|
159
|
+
*/
|
|
160
|
+
export function cleanBuildArtifacts(
|
|
161
|
+
root,
|
|
162
|
+
{ extra = [], includeNodeModules = false } = {},
|
|
163
|
+
) {
|
|
164
|
+
const dirs = [
|
|
165
|
+
...BUILD_ARTIFACT_DIRS,
|
|
166
|
+
...extra,
|
|
167
|
+
...(includeNodeModules ? ["node_modules"] : []),
|
|
168
|
+
];
|
|
169
|
+
const removed = [];
|
|
170
|
+
for (const dir of dirs) {
|
|
171
|
+
const target = path.resolve(root, dir);
|
|
172
|
+
if (!fs.existsSync(target)) continue;
|
|
173
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
174
|
+
removed.push(dir);
|
|
175
|
+
}
|
|
176
|
+
return removed;
|
|
177
|
+
}
|
package/text.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
* Locating a literal inside an arbitrary text file.
|
|
16
|
+
*
|
|
17
|
+
* A build check reports a **finding**, and a finding is only actionable if it
|
|
18
|
+
* says where it is (#1668). Most findings are *about* a string the check
|
|
19
|
+
* matched — a key, a marker, a caption — so its position is one string search
|
|
20
|
+
* away, and making that search is the difference between a finding that can be
|
|
21
|
+
* opened and one that has to be hunted for.
|
|
22
|
+
*
|
|
23
|
+
* `@heroiclands/content-build` owns the diagnostic **format**, and its
|
|
24
|
+
* `positionInBody` maps an offset within a parsed content note back to its
|
|
25
|
+
* file. That is a different job: the checks here read localization files,
|
|
26
|
+
* manifests, source and bundles — none of which are notes. So this module
|
|
27
|
+
* carries the generic operation, and nothing carries it twice.
|
|
28
|
+
*
|
|
29
|
+
* Plain ESM with no filesystem access, so it is unit-testable.
|
|
30
|
+
*
|
|
31
|
+
* @module
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where a literal sits in a text.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} text - The file's contents.
|
|
38
|
+
* @param {string} needle - The literal to locate.
|
|
39
|
+
* @param {number} [occurrence] - Which occurrence, 1-based. Repeats of the same
|
|
40
|
+
* literal are otherwise indistinguishable, which is the symptom the
|
|
41
|
+
* diagnostic format exists to remove.
|
|
42
|
+
* @returns {{line: number, column: number}|undefined} 1-based position, or
|
|
43
|
+
* `undefined` when the literal is not there. A caller that gets `undefined`
|
|
44
|
+
* reports the file alone rather than a position that is not the problem.
|
|
45
|
+
*/
|
|
46
|
+
export function locateInText(text, needle, occurrence = 1) {
|
|
47
|
+
if (typeof text !== "string" || !needle) return undefined;
|
|
48
|
+
let at = -1;
|
|
49
|
+
for (let n = 0; n < occurrence; n++) {
|
|
50
|
+
at = text.indexOf(needle, at + 1);
|
|
51
|
+
if (at === -1) return undefined;
|
|
52
|
+
}
|
|
53
|
+
const before = text.slice(0, at);
|
|
54
|
+
return {
|
|
55
|
+
line: before.split("\n").length,
|
|
56
|
+
column: at - before.lastIndexOf("\n"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where a literal sits, as spreadable diagnostic fields.
|
|
62
|
+
*
|
|
63
|
+
* Keeps the drop-rather-than-guess rule in one place: an unfound literal
|
|
64
|
+
* contributes no position at all, rather than `undefined` fields that read as a
|
|
65
|
+
* bug or a `1:1` that sends the reader to the top of the file.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} text - The file's contents.
|
|
68
|
+
* @param {string} needle - The literal to locate.
|
|
69
|
+
* @param {number} [occurrence] - Which occurrence, 1-based.
|
|
70
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields.
|
|
71
|
+
*/
|
|
72
|
+
export function positionOf(text, needle, occurrence = 1) {
|
|
73
|
+
return locateInText(text, needle, occurrence) ?? {};
|
|
74
|
+
}
|