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