@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/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
+ }