@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/deploy.mjs
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
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
|
+
* Deploying a staged package into a Foundry data directory.
|
|
16
|
+
*
|
|
17
|
+
* **The deploy is staged and swapped, never written in place.** That is the
|
|
18
|
+
* whole design, and it exists for one reason: a running Foundry holds its
|
|
19
|
+
* LevelDB compendium packs open. Replacing pack files underneath a live server
|
|
20
|
+
* leaves LevelDB an inconsistent directory, which it "repairs" on its next open
|
|
21
|
+
* — to zero. So the build is written to a sibling `….staging-<pid>` directory
|
|
22
|
+
* and renamed into place; the old tree is renamed aside first, so a process
|
|
23
|
+
* holding its inodes keeps reading the bytes it already had, and the new build
|
|
24
|
+
* takes effect on the next world reload.
|
|
25
|
+
*
|
|
26
|
+
* Two transports, chosen from the destination rather than configured: a local
|
|
27
|
+
* path is copied, and a `[user@]host:/path` target is uploaded over SFTP. Both
|
|
28
|
+
* perform the same staged swap.
|
|
29
|
+
*
|
|
30
|
+
* **No secret is ever read from disk by default.** SFTP authenticates through
|
|
31
|
+
* the running SSH agent, cross-platform; an explicit key *path* is the escape
|
|
32
|
+
* hatch for agent-less setups, and no passphrase or password is read from the
|
|
33
|
+
* environment.
|
|
34
|
+
*
|
|
35
|
+
* The rules are pure functions over data; the functions that touch a
|
|
36
|
+
* filesystem or a network are named for what they do.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import fs from "node:fs/promises";
|
|
42
|
+
import path from "node:path";
|
|
43
|
+
import process from "node:process";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The environment variable naming each stage's Foundry data root.
|
|
47
|
+
*
|
|
48
|
+
* The set is a shared convention rather than one repository's: every
|
|
49
|
+
* HeroicLands package deploys to the same four stages, and a `test` root is
|
|
50
|
+
* what the end-to-end harness seeds its throwaway world into.
|
|
51
|
+
*/
|
|
52
|
+
export const STAGE_ENV_MAP = Object.freeze({
|
|
53
|
+
dev: "FOUNDRYVTT_DEV_DATA",
|
|
54
|
+
qa: "FOUNDRYVTT_QA_DATA",
|
|
55
|
+
prod: "FOUNDRYVTT_PROD_DATA",
|
|
56
|
+
test: "FOUNDRYVTT_TEST_DATA",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Normalise a stage argument.
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} stageArg - Whatever the caller was given.
|
|
63
|
+
* @returns {string} The trimmed, lowercased stage name, `""` when absent.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveStage(stageArg) {
|
|
66
|
+
return String(stageArg || "")
|
|
67
|
+
.trim()
|
|
68
|
+
.toLowerCase();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Where a package installs beneath a Foundry data root.
|
|
73
|
+
*
|
|
74
|
+
* Foundry keeps systems and modules in sibling trees under `Data`, and the leaf
|
|
75
|
+
* is the package id — the same id the manifest declares and every compendium
|
|
76
|
+
* UUID starts with. Deriving it here is what lets one deploy serve a system and
|
|
77
|
+
* a module without either naming its own path.
|
|
78
|
+
*
|
|
79
|
+
* @param {"systems"|"modules"} packageKind - Which tree it installs into.
|
|
80
|
+
* @param {string} packageId - The Foundry package id.
|
|
81
|
+
* @returns {string[]} Path segments beneath the data root.
|
|
82
|
+
*/
|
|
83
|
+
export function packageSubpath(packageKind, packageId) {
|
|
84
|
+
if (packageKind !== "systems" && packageKind !== "modules") {
|
|
85
|
+
throw new TypeError(
|
|
86
|
+
`packageKind must be "systems" or "modules", not ${JSON.stringify(packageKind)}.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (!packageId) {
|
|
90
|
+
throw new TypeError("packageId is required to locate the deploy path.");
|
|
91
|
+
}
|
|
92
|
+
return ["Data", packageKind, packageId];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether a destination names a remote host rather than a local directory.
|
|
97
|
+
*
|
|
98
|
+
* A remote target is `[user@]host:/path`. The colon is what distinguishes it —
|
|
99
|
+
* **except on Windows, where `C:\Foundry\Data` also has one**. A bare drive
|
|
100
|
+
* letter followed by a separator is therefore read as local; without that, a
|
|
101
|
+
* Windows developer's perfectly ordinary path is parsed as a host called `C`
|
|
102
|
+
* and the deploy fails trying to open an SSH connection to it.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} target - The configured destination.
|
|
105
|
+
* @returns {boolean} True when it should be deployed over SFTP.
|
|
106
|
+
*/
|
|
107
|
+
export function isRemoteTarget(target) {
|
|
108
|
+
const value = String(target ?? "").trim();
|
|
109
|
+
if (!value || value.startsWith("/")) return false;
|
|
110
|
+
// A Windows drive root: one letter, a colon, then a separator.
|
|
111
|
+
if (/^[A-Za-z]:[\\/]/.test(value)) return false;
|
|
112
|
+
return value.indexOf(":") > 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse a `[user@]host:/path` remote target into its parts.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} target - The remote destination.
|
|
119
|
+
* @returns {{username: string|undefined, host: string, remotePath: string}}
|
|
120
|
+
*/
|
|
121
|
+
export function parseRemote(target) {
|
|
122
|
+
const colonIdx = target.indexOf(":");
|
|
123
|
+
const authority = target.slice(0, colonIdx);
|
|
124
|
+
const remotePath = target.slice(colonIdx + 1);
|
|
125
|
+
const atIdx = authority.indexOf("@");
|
|
126
|
+
const username = atIdx > 0 ? authority.slice(0, atIdx) : undefined;
|
|
127
|
+
const host = atIdx > 0 ? authority.slice(atIdx + 1) : authority;
|
|
128
|
+
return { username, host, remotePath };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Locate the SSH agent endpoint, cross-platform.
|
|
133
|
+
*
|
|
134
|
+
* Precedence: an explicit per-stage override (use `"pageant"` for PuTTY, or a
|
|
135
|
+
* named-pipe path), then `$SSH_AUTH_SOCK`, then the Windows OpenSSH agent's
|
|
136
|
+
* default named pipe. `undefined` when no agent is available, at which point a
|
|
137
|
+
* caller falls back to a key file.
|
|
138
|
+
*
|
|
139
|
+
* @param {NodeJS.ProcessEnv} env - The environment to read.
|
|
140
|
+
* @param {string} stageUpper - Uppercased stage name, e.g. `"QA"`.
|
|
141
|
+
* @param {string} [prefix] - Fallback variable prefix for a shared override.
|
|
142
|
+
* @returns {string|undefined} The agent endpoint.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveAgent(env, stageUpper, prefix = "SOHL") {
|
|
145
|
+
return (
|
|
146
|
+
env[`FOUNDRYVTT_${stageUpper}_AGENT`] ||
|
|
147
|
+
env[`${prefix}_SFTP_AGENT`] ||
|
|
148
|
+
env.SSH_AUTH_SOCK ||
|
|
149
|
+
(process.platform === "win32" ?
|
|
150
|
+
"\\\\.\\pipe\\openssh-ssh-agent"
|
|
151
|
+
: undefined)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Assemble an `ssh2-sftp-client` connection config for a stage.
|
|
157
|
+
*
|
|
158
|
+
* Defaults to the SSH agent so no secret is read from disk. An explicit key
|
|
159
|
+
* *path* — not a secret — is the escape hatch; no passphrase or password is
|
|
160
|
+
* read from the environment, deliberately, so none ends up in a `.env` file.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} stageUpper - Uppercased stage name, e.g. `"QA"`.
|
|
163
|
+
* @param {{username: string|undefined, host: string}} remote - Parsed target.
|
|
164
|
+
* @param {object} [opts]
|
|
165
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - The environment to read.
|
|
166
|
+
* @param {string} [opts.prefix] - Fallback variable prefix.
|
|
167
|
+
* @returns {Promise<object>} The connection config.
|
|
168
|
+
*/
|
|
169
|
+
export async function buildConnection(
|
|
170
|
+
stageUpper,
|
|
171
|
+
remote,
|
|
172
|
+
{ env = process.env, prefix = "SOHL" } = {},
|
|
173
|
+
) {
|
|
174
|
+
const port = Number(
|
|
175
|
+
env[`FOUNDRYVTT_${stageUpper}_PORT`] ??
|
|
176
|
+
env[`${prefix}_SFTP_PORT`] ??
|
|
177
|
+
22,
|
|
178
|
+
);
|
|
179
|
+
const username =
|
|
180
|
+
remote.username || env[`FOUNDRYVTT_${stageUpper}_USER`] || env.USER;
|
|
181
|
+
|
|
182
|
+
const conn = { host: remote.host, port, username };
|
|
183
|
+
|
|
184
|
+
const keyPath = env[`FOUNDRYVTT_${stageUpper}_KEY`];
|
|
185
|
+
if (keyPath) conn.privateKey = await fs.readFile(keyPath);
|
|
186
|
+
else {
|
|
187
|
+
const agent = resolveAgent(env, stageUpper, prefix);
|
|
188
|
+
if (agent) conn.agent = agent;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return conn;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @param {string} p @returns {Promise<boolean>} Whether the path exists. */
|
|
195
|
+
async function exists(p) {
|
|
196
|
+
try {
|
|
197
|
+
await fs.stat(p);
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Mirror the staged build into a local directory via a staged, atomic swap.
|
|
206
|
+
*
|
|
207
|
+
* The live destination is never mutated in place — see the module header for
|
|
208
|
+
* why that matters under a running server.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} srcAbs - The staged tree.
|
|
211
|
+
* @param {string} destDir - Where the package installs.
|
|
212
|
+
* @returns {Promise<void>}
|
|
213
|
+
*/
|
|
214
|
+
export async function deployLocal(srcAbs, destDir) {
|
|
215
|
+
const staging = `${destDir}.staging-${process.pid}`;
|
|
216
|
+
const old = `${destDir}.old-${process.pid}`;
|
|
217
|
+
|
|
218
|
+
// 1. Build a fresh staging copy alongside the destination.
|
|
219
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
220
|
+
await fs.mkdir(path.dirname(destDir), { recursive: true });
|
|
221
|
+
await fs.cp(srcAbs, staging, { recursive: true });
|
|
222
|
+
|
|
223
|
+
// 2. Swap it in with renames. Renaming onto a non-existent name is atomic;
|
|
224
|
+
// the old tree is moved aside first — its inodes stay alive for any
|
|
225
|
+
// process holding them open — and only then removed.
|
|
226
|
+
await fs.rm(old, { recursive: true, force: true });
|
|
227
|
+
const hadDest = await exists(destDir);
|
|
228
|
+
if (hadDest) await fs.rename(destDir, old);
|
|
229
|
+
await fs.rename(staging, destDir);
|
|
230
|
+
if (hadDest) await fs.rm(old, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Mirror the staged build into a remote directory over SFTP, with the same
|
|
235
|
+
* staged swap {@link deployLocal} performs.
|
|
236
|
+
*
|
|
237
|
+
* `ssh2-sftp-client` is imported here rather than at module scope so that the
|
|
238
|
+
* pure helpers above — and a local deploy — cost nothing to import.
|
|
239
|
+
*
|
|
240
|
+
* @param {object} conn - Connection config from {@link buildConnection}.
|
|
241
|
+
* @param {string} srcAbs - The staged tree.
|
|
242
|
+
* @param {string} remoteDir - Where the package installs on the host.
|
|
243
|
+
* @param {object} [opts]
|
|
244
|
+
* @param {(uploaded: string) => void} [opts.onUpload] - Per-file progress.
|
|
245
|
+
* @returns {Promise<void>}
|
|
246
|
+
*/
|
|
247
|
+
export async function deployRemote(conn, srcAbs, remoteDir, { onUpload } = {}) {
|
|
248
|
+
const { default: Client } = await import("ssh2-sftp-client");
|
|
249
|
+
const staging = `${remoteDir}.staging-${process.pid}`;
|
|
250
|
+
const old = `${remoteDir}.old-${process.pid}`;
|
|
251
|
+
const sftp = new Client();
|
|
252
|
+
if (onUpload) sftp.on("upload", ({ source }) => onUpload(source));
|
|
253
|
+
await sftp.connect(conn);
|
|
254
|
+
try {
|
|
255
|
+
// `mkdir` is recursive, so it also creates the parent tree on a
|
|
256
|
+
// first-ever deploy.
|
|
257
|
+
if (await sftp.exists(staging)) await sftp.rmdir(staging, true);
|
|
258
|
+
await sftp.mkdir(staging, true);
|
|
259
|
+
await sftp.uploadDir(srcAbs, staging);
|
|
260
|
+
|
|
261
|
+
if (await sftp.exists(old)) await sftp.rmdir(old, true);
|
|
262
|
+
const hadDest = Boolean(await sftp.exists(remoteDir));
|
|
263
|
+
if (hadDest) await sftp.rename(remoteDir, old);
|
|
264
|
+
await sftp.rename(staging, remoteDir);
|
|
265
|
+
if (hadDest) await sftp.rmdir(old, true);
|
|
266
|
+
} finally {
|
|
267
|
+
await sftp.end();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Deploy a staged package to one stage, choosing the transport from the
|
|
273
|
+
* configured destination.
|
|
274
|
+
*
|
|
275
|
+
* @param {object} opts
|
|
276
|
+
* @param {string} opts.stage - `dev` / `qa` / `prod` / `test`.
|
|
277
|
+
* @param {string} opts.source - The staged tree.
|
|
278
|
+
* @param {"systems"|"modules"} opts.packageKind - Which Foundry tree.
|
|
279
|
+
* @param {string} opts.packageId - The Foundry package id.
|
|
280
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - The environment to read.
|
|
281
|
+
* @param {string} [opts.prefix] - Fallback variable prefix for SFTP overrides.
|
|
282
|
+
* @param {(message: string) => void} [opts.log] - Progress reporting.
|
|
283
|
+
* @returns {Promise<{stage: string, destination: string, remote: boolean}>}
|
|
284
|
+
* @throws {Error} On an unknown stage, or one with no destination configured.
|
|
285
|
+
*/
|
|
286
|
+
export async function deployStage({
|
|
287
|
+
stage,
|
|
288
|
+
source,
|
|
289
|
+
packageKind,
|
|
290
|
+
packageId,
|
|
291
|
+
env = process.env,
|
|
292
|
+
prefix = "SOHL",
|
|
293
|
+
log = () => {},
|
|
294
|
+
}) {
|
|
295
|
+
const name = resolveStage(stage);
|
|
296
|
+
const envVarName = STAGE_ENV_MAP[name];
|
|
297
|
+
if (!envVarName) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`Invalid stage ${JSON.stringify(stage)}. Valid stages are: ` +
|
|
300
|
+
`${Object.keys(STAGE_ENV_MAP).join(", ")}.`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const dataRoot = env[envVarName]?.trim() ?? "";
|
|
305
|
+
if (!dataRoot) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`No destination configured for stage '${name}'. Set ${envVarName} ` +
|
|
308
|
+
`— for example ${envVarName}="/path/to/foundryvtt/data".`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const segments = packageSubpath(packageKind, packageId);
|
|
313
|
+
|
|
314
|
+
if (isRemoteTarget(dataRoot)) {
|
|
315
|
+
const remote = parseRemote(dataRoot);
|
|
316
|
+
const remoteDir = path.posix.join(remote.remotePath, ...segments);
|
|
317
|
+
const conn = await buildConnection(name.toUpperCase(), remote, {
|
|
318
|
+
env,
|
|
319
|
+
prefix,
|
|
320
|
+
});
|
|
321
|
+
log(
|
|
322
|
+
`Deploying ${source} → ${conn.username}@${conn.host}:${remoteDir} (sftp)`,
|
|
323
|
+
);
|
|
324
|
+
await deployRemote(conn, source, remoteDir, {
|
|
325
|
+
onUpload: (f) => log(` ${f}`),
|
|
326
|
+
});
|
|
327
|
+
return { stage: name, destination: remoteDir, remote: true };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const destDir = path.join(dataRoot, ...segments);
|
|
331
|
+
log(`Deploying ${source} → ${destDir} (local copy)`);
|
|
332
|
+
await deployLocal(source, destDir);
|
|
333
|
+
return { stage: name, destination: destDir, remote: false };
|
|
334
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
* `@heroiclands/package-build` — the shared toolchain for building and shipping
|
|
16
|
+
* a HeroicLands **Foundry package**.
|
|
17
|
+
*
|
|
18
|
+
* It is the counterpart to `@heroiclands/content-build`, and the two split by
|
|
19
|
+
* **input**, not by repository:
|
|
20
|
+
*
|
|
21
|
+
* - content-build reads `assets/content/**` and produces compendium packs, site
|
|
22
|
+
* content and the link manifest. It answers for what a package *says*.
|
|
23
|
+
* - this package reads `lang/`, `styles/`, `src/`, `assets/` and the manifest
|
|
24
|
+
* template. It answers for what a package *is* — the parts Foundry loads
|
|
25
|
+
* whether or not the package ships any content at all.
|
|
26
|
+
*
|
|
27
|
+
* A module uses either, or both. An adventure module that ships only notes
|
|
28
|
+
* needs no bundler; a variant module that ships only behavior needs no Markdown
|
|
29
|
+
* pipeline. The coupling between the two packages runs one way — this one asks
|
|
30
|
+
* content-build for the compiled `packs[]` block, never the reverse.
|
|
31
|
+
*
|
|
32
|
+
* **Everything exported here is pure.** Functions take source text and return
|
|
33
|
+
* findings or values; discovery, I/O and reporting stay with the caller. That
|
|
34
|
+
* is what lets one rule set serve a `lint` script, a build step and a unit test
|
|
35
|
+
* without any of them having to agree on how files are found or how findings
|
|
36
|
+
* are printed — and it is what makes the rules testable at all, which the
|
|
37
|
+
* scripts these were extracted from were not.
|
|
38
|
+
*
|
|
39
|
+
* @module
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The Foundry package manifest: `system.json` / `module.json`. */
|
|
43
|
+
export * as manifest from "./manifest.mjs";
|
|
44
|
+
|
|
45
|
+
/** The code bundle, and whether the manifest agrees with how it parses. */
|
|
46
|
+
export * as bundle from "./bundle.mjs";
|
|
47
|
+
|
|
48
|
+
/** Assembling the build stage, and clearing it away again. */
|
|
49
|
+
export * as stage from "./stage.mjs";
|
|
50
|
+
|
|
51
|
+
/** The release archive a GitHub Release carries. */
|
|
52
|
+
export * as release from "./release.mjs";
|
|
53
|
+
|
|
54
|
+
/** Deploying a staged package into a Foundry data directory. */
|
|
55
|
+
export * as deploy from "./deploy.mjs";
|
|
56
|
+
|
|
57
|
+
/** Localization files: what a shippable `lang/*.json` must satisfy. */
|
|
58
|
+
export * as lang from "./lang.mjs";
|
|
59
|
+
|
|
60
|
+
/** Locating a literal inside an arbitrary text file, for positioned findings. */
|
|
61
|
+
export * as text from "./text.mjs";
|
package/lang.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
* What a shippable Foundry localization file must satisfy.
|
|
16
|
+
*
|
|
17
|
+
* Every HeroicLands package ships `lang/*.json` and declares it under
|
|
18
|
+
* `languages` in its manifest, so every package can break it the same ways —
|
|
19
|
+
* and each way fails **silently**, which is what makes a shared guard worth
|
|
20
|
+
* more than a shared convention:
|
|
21
|
+
*
|
|
22
|
+
* - **Not an object.** Foundry hands the parsed file to
|
|
23
|
+
* `foundry.utils.expandObject`, which expects a record. A file that parses
|
|
24
|
+
* but is an array yields index-keyed entries and localizes nothing.
|
|
25
|
+
* `sohl-kethira-basic/lang/en.json` is authored as `[ "KEY": "value", … ]` —
|
|
26
|
+
* an array wrapping object pairs — and has never loaded.
|
|
27
|
+
* - **A dotted-prefix collision.** When one key is a strict dotted prefix of
|
|
28
|
+
* another (`"SOHL.Trauma.Pall"` beside `"SOHL.Trauma.Pall.Note.Resist"`),
|
|
29
|
+
* `expandObject` **throws**: it cannot create a `Note` property on the string
|
|
30
|
+
* `"The Pall"`. Foundry catches that throw and discards the **entire** file,
|
|
31
|
+
* so one colliding pair drops every translation in it and each string renders
|
|
32
|
+
* as its raw key (#636). A key must be a leaf **or** a branch, never both.
|
|
33
|
+
* - **A Handlebars placeholder.** Foundry interpolates with `format()` and
|
|
34
|
+
* SINGLE braces, so a `{{…}}` value renders literally unless some call site
|
|
35
|
+
* happens to hand it to a Handlebars pass (#1353).
|
|
36
|
+
* - **Data baked into a key segment.** A segment carrying anything but
|
|
37
|
+
* `[A-Za-z0-9_-]` is a path or a UUID in a key, and a dotted payload is how
|
|
38
|
+
* the collision above gets in (#636, #1351).
|
|
39
|
+
*
|
|
40
|
+
* Every function here is pure — it takes source text and returns findings, and
|
|
41
|
+
* touches no filesystem and emits nothing. The caller owns discovery and
|
|
42
|
+
* reporting, which is what lets one rule set serve a `lint` script, a build
|
|
43
|
+
* step and a unit test without any of them agreeing on I/O.
|
|
44
|
+
*
|
|
45
|
+
* @module
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { positionOf } from "./text.mjs";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A single finding, in the fields the shared diagnostic format takes.
|
|
52
|
+
*
|
|
53
|
+
* `file` is deliberately absent: these functions are handed source text, not a
|
|
54
|
+
* path, so the caller — which knows where the text came from — supplies it.
|
|
55
|
+
*
|
|
56
|
+
* @typedef {object} LangFinding
|
|
57
|
+
* @property {number} [line] - 1-based line, omitted when it cannot be
|
|
58
|
+
* established honestly.
|
|
59
|
+
* @property {number} [column] - 1-based column, omitted likewise.
|
|
60
|
+
* @property {"error"|"warning"} severity - How the finding should be treated.
|
|
61
|
+
* @property {string} message - What is wrong, in one sentence.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/** Key segments may carry only these characters. */
|
|
65
|
+
const SEGMENT = /^[A-Za-z0-9_-]*$/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every `[prefixKey, leafKey]` pair where `prefixKey` is a strict dotted prefix
|
|
69
|
+
* of `leafKey` and both are present as keys — the exact shape that makes
|
|
70
|
+
* `foundry.utils.expandObject` throw.
|
|
71
|
+
*
|
|
72
|
+
* @param {Record<string, unknown>} json - The parsed, flat localization object.
|
|
73
|
+
* @returns {[string, string][]} The colliding `[prefix, leaf]` pairs.
|
|
74
|
+
*/
|
|
75
|
+
export function findPrefixCollisions(json) {
|
|
76
|
+
const keys = Object.keys(json);
|
|
77
|
+
const keySet = new Set(keys);
|
|
78
|
+
const collisions = [];
|
|
79
|
+
for (const key of keys) {
|
|
80
|
+
const parts = key.split(".");
|
|
81
|
+
for (let i = 1; i < parts.length; i++) {
|
|
82
|
+
const prefix = parts.slice(0, i).join(".");
|
|
83
|
+
if (keySet.has(prefix)) collisions.push([prefix, key]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return collisions;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether a parsed value is a plain record Foundry can expand.
|
|
91
|
+
*
|
|
92
|
+
* An array is the case worth naming: it is valid JSON, it survives
|
|
93
|
+
* `Object.entries`, and it therefore passes every other rule here while
|
|
94
|
+
* localizing nothing.
|
|
95
|
+
*
|
|
96
|
+
* @param {unknown} value - The parsed top-level value.
|
|
97
|
+
* @returns {boolean} True when the value is a non-null, non-array object.
|
|
98
|
+
*/
|
|
99
|
+
function isRecord(value) {
|
|
100
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate one localization file's source text.
|
|
105
|
+
*
|
|
106
|
+
* Findings are returned in file order where a position is known, so a caller
|
|
107
|
+
* that prints them walks the file top to bottom.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} raw - The file's contents.
|
|
110
|
+
* @returns {LangFinding[]} Every finding, empty when the file is shippable.
|
|
111
|
+
*/
|
|
112
|
+
export function validateLangSource(raw) {
|
|
113
|
+
let json;
|
|
114
|
+
try {
|
|
115
|
+
json = JSON.parse(raw);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Nothing further can be said about a file that does not parse, and
|
|
118
|
+
// guessing at its intended shape would only bury this finding.
|
|
119
|
+
return [
|
|
120
|
+
{ severity: "error", message: `not valid JSON: ${err.message}` },
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!isRecord(json)) {
|
|
125
|
+
const shape = Array.isArray(json) ? "an array" : `a ${typeof json}`;
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
severity: "error",
|
|
129
|
+
message:
|
|
130
|
+
`top level is ${shape}; a localization file must be a JSON ` +
|
|
131
|
+
"object, or Foundry expands it to nothing",
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const findings = [];
|
|
137
|
+
/**
|
|
138
|
+
* Where a key is declared in the file.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} key - The localization key.
|
|
141
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields.
|
|
142
|
+
*/
|
|
143
|
+
const at = (key) => positionOf(raw, `"${key}"`);
|
|
144
|
+
|
|
145
|
+
for (const [key, value] of Object.entries(json)) {
|
|
146
|
+
if (typeof value !== "string") continue;
|
|
147
|
+
|
|
148
|
+
if (/\{\{|\}\}/.test(value)) {
|
|
149
|
+
findings.push({
|
|
150
|
+
...at(key),
|
|
151
|
+
severity: "error",
|
|
152
|
+
message:
|
|
153
|
+
`"${key}" uses Handlebars double braces; Foundry ` +
|
|
154
|
+
"placeholders are single-braced {camelCase}",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const braces = value.split("").reduce(
|
|
159
|
+
(n, c) =>
|
|
160
|
+
n +
|
|
161
|
+
(c === "{" ? 1
|
|
162
|
+
: c === "}" ? -1
|
|
163
|
+
: 0),
|
|
164
|
+
0,
|
|
165
|
+
);
|
|
166
|
+
if (braces !== 0) {
|
|
167
|
+
findings.push({
|
|
168
|
+
...at(key),
|
|
169
|
+
severity: "error",
|
|
170
|
+
message: `"${key}" has an unbalanced brace`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const key of Object.keys(json)) {
|
|
176
|
+
const bad = key.split(".").filter((seg) => !SEGMENT.test(seg));
|
|
177
|
+
if (bad.length) {
|
|
178
|
+
findings.push({
|
|
179
|
+
...at(key),
|
|
180
|
+
severity: "error",
|
|
181
|
+
message:
|
|
182
|
+
`"${key}" has a segment outside [A-Za-z0-9_-]: ` +
|
|
183
|
+
`${bad.map((b) => `"${b}"`).join(", ")}`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const [prefix, leaf] of findPrefixCollisions(json)) {
|
|
189
|
+
findings.push({
|
|
190
|
+
...at(prefix),
|
|
191
|
+
severity: "error",
|
|
192
|
+
message: `"${prefix}" is a leaf but also a prefix of "${leaf}"`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return findings;
|
|
197
|
+
}
|