@heroiclands/package-build 0.5.0 → 0.6.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/index.mjs CHANGED
@@ -54,5 +54,17 @@ export * as release from "./release.mjs";
54
54
  /** Deploying a staged package into a Foundry data directory. */
55
55
  export * as deploy from "./deploy.mjs";
56
56
 
57
+ /** Running a built package inside a Foundry VTT container. */
58
+ export * as container from "./container.mjs";
59
+
60
+ /** The end-to-end harness: a disposable world, served, with a suite driven at it. */
61
+ export * as e2e from "./e2e.mjs";
62
+
57
63
  /** Localization files: what a shippable `lang/*.json` must satisfy. */
58
64
  export * as lang from "./lang.mjs";
65
+
66
+ /** Localization coverage: the keys a package references against the keys it declares. */
67
+ export * as coverage from "./coverage.mjs";
68
+
69
+ /** Templates: whether their user-visible text is localized, and whether they compile. */
70
+ export * as templates from "./templates.mjs";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "0.5.0",
4
- "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 manifest, localization, staging, bundle, release and deployment.",
3
+ "version": "0.6.0",
4
+ "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
7
7
  "main": "./index.mjs",
@@ -15,10 +15,26 @@
15
15
  "types": "./types/bundle.d.mts",
16
16
  "import": "./bundle.mjs"
17
17
  },
18
+ "./config": {
19
+ "types": "./types/config.d.mts",
20
+ "import": "./config.mjs"
21
+ },
22
+ "./container": {
23
+ "types": "./types/container.d.mts",
24
+ "import": "./container.mjs"
25
+ },
26
+ "./coverage": {
27
+ "types": "./types/coverage.d.mts",
28
+ "import": "./coverage.mjs"
29
+ },
18
30
  "./deploy": {
19
31
  "types": "./types/deploy.d.mts",
20
32
  "import": "./deploy.mjs"
21
33
  },
34
+ "./e2e": {
35
+ "types": "./types/e2e.d.mts",
36
+ "import": "./e2e.mjs"
37
+ },
22
38
  "./lang": {
23
39
  "types": "./types/lang.d.mts",
24
40
  "import": "./lang.mjs"
@@ -27,6 +43,7 @@
27
43
  "types": "./types/manifest.d.mts",
28
44
  "import": "./manifest.mjs"
29
45
  },
46
+ "./package.json": "./package.json",
30
47
  "./release": {
31
48
  "types": "./types/release.d.mts",
32
49
  "import": "./release.mjs"
@@ -35,25 +52,28 @@
35
52
  "types": "./types/stage.d.mts",
36
53
  "import": "./stage.mjs"
37
54
  },
38
- "./package.json": "./package.json",
39
- "./config": {
40
- "types": "./types/config.d.mts",
41
- "import": "./config.mjs"
55
+ "./templates": {
56
+ "types": "./types/templates.d.mts",
57
+ "import": "./templates.mjs"
42
58
  }
43
59
  },
44
60
  "files": [
61
+ "CHANGELOG.md",
62
+ "README.md",
63
+ "bin",
45
64
  "bundle.mjs",
65
+ "config.mjs",
66
+ "container.mjs",
67
+ "coverage.mjs",
46
68
  "deploy.mjs",
69
+ "e2e.mjs",
47
70
  "index.mjs",
48
71
  "lang.mjs",
49
72
  "manifest.mjs",
50
73
  "release.mjs",
51
74
  "stage.mjs",
52
- "types",
53
- "CHANGELOG.md",
54
- "README.md",
55
- "bin",
56
- "config.mjs"
75
+ "templates.mjs",
76
+ "types"
57
77
  ],
58
78
  "scripts": {
59
79
  "test": "vitest run",
@@ -67,19 +87,21 @@
67
87
  "prepare": "git config core.hooksPath .githooks || true"
68
88
  },
69
89
  "dependencies": {
90
+ "@foundryvtt/foundryvtt-cli": "^3.0.4",
70
91
  "@heroiclands/content-build": "^1.0.0",
71
92
  "acorn": "^8.18.0",
72
93
  "archiver": "^8.0.0",
73
94
  "dotenv": "^17.2.3",
74
95
  "glob": "^11.0.3",
96
+ "handlebars": "^4.7.9",
75
97
  "ssh2-sftp-client": "^12.1.1",
98
+ "typescript": "^6.0.3",
76
99
  "yargs": "^18.1.0"
77
100
  },
78
101
  "devDependencies": {
79
102
  "@changesets/cli": "^3.0.0",
80
103
  "@types/node": "^26.2.0",
81
104
  "prettier": "^3.9.6",
82
- "typescript": "^6.0.3",
83
105
  "vitest": "^4.1.10"
84
106
  },
85
107
  "engines": {
package/templates.mjs ADDED
@@ -0,0 +1,224 @@
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
+ * Whether a template's user-visible text goes through localization at all.
16
+ *
17
+ * This is the **reverse** of {@link module:coverage}, and the two are
18
+ * deliberate opposites. Coverage walks *key → file*: it can tell you that every
19
+ * key the code names exists, and it is completely blind to a template that
20
+ * names no key whatsoever. This walks *text → key*: every user-visible literal
21
+ * in the markup must be a `{{localize}}` call rather than English sitting in
22
+ * the file.
23
+ *
24
+ * The Song of Heroic Lands repository is the argument for having both. Before
25
+ * the work that prompted this guard there were **516 hardcoded English literals
26
+ * across 61 templates**, and translating every key in `en.json` would have left
27
+ * every one of them in English — a fully "translated" package that renders half
28
+ * in the translator's language and half in the author's.
29
+ *
30
+ * It also **compiles** every template, because the usual way to break one while
31
+ * localizing it is to nest `{{localize …}}` inside another mustache. That is
32
+ * legal in an HTML attribute and a parse error inside a helper's hash, where a
33
+ * `(localize …)` subexpression is required — so the mistake ships from a
34
+ * template that looks exactly like its working neighbour.
35
+ *
36
+ * Both functions are pure: source text in, findings out. Discovery, I/O and
37
+ * reporting stay with the caller.
38
+ *
39
+ * @module
40
+ */
41
+
42
+ import Handlebars from "handlebars";
43
+ import { positionOfLiteral } from "@heroiclands/content-build/engine/diagnostics";
44
+
45
+ /**
46
+ * A single finding, in the fields the shared diagnostic format takes.
47
+ *
48
+ * `file` is absent for the same reason it is absent from a localization
49
+ * finding: these functions are handed source text, not a path.
50
+ *
51
+ * @typedef {object} TemplateFinding
52
+ * @property {number} [line] - 1-based line, omitted when it cannot be
53
+ * established honestly.
54
+ * @property {number} [column] - 1-based column, omitted likewise.
55
+ * @property {"error"|"warning"} severity - How the finding should be treated.
56
+ * @property {string} message - What is wrong, in one sentence.
57
+ */
58
+
59
+ /**
60
+ * Attributes whose value the user reads.
61
+ *
62
+ * Every one of these renders as prose somewhere — a tooltip, a placeholder, a
63
+ * screen reader's announcement — so English in one is as untranslated as
64
+ * English in a heading, and far easier to miss.
65
+ *
66
+ * @type {readonly string[]}
67
+ */
68
+ export const VISIBLE_ATTRIBUTES = Object.freeze([
69
+ "title",
70
+ "placeholder",
71
+ "aria-label",
72
+ "alt",
73
+ "data-tooltip",
74
+ "data-title",
75
+ ]);
76
+
77
+ /**
78
+ * Remove everything that is not user-visible prose.
79
+ *
80
+ * Handlebars expressions go first (their contents are code), then `<style>` and
81
+ * `<script>` bodies (theirs are too). What is left is what a player reads.
82
+ *
83
+ * Substitutions are single spaces rather than removals so that nothing new is
84
+ * glued together — `>{{a}}<` must not become `><`, which would read as an empty
85
+ * text node rather than as no text node at all.
86
+ *
87
+ * @param {string} source - The template source.
88
+ * @returns {string} The source with every non-prose region blanked.
89
+ */
90
+ function stripNonProse(source) {
91
+ return source
92
+ .replace(/\{\{![\s\S]*?\}\}/g, " ")
93
+ .replace(/\{\{[^}]*\}\}/g, " ")
94
+ .replace(/<style[\s\S]*?<\/style>/g, " ")
95
+ .replace(/<script[\s\S]*?<\/script>/g, " ");
96
+ }
97
+
98
+ /**
99
+ * Whether a run of text is prose a player would expect in their own language.
100
+ *
101
+ * An HTML entity (`&infin;`, `&middot;`) is a symbol, not prose — its letters
102
+ * are markup — so entities are dropped before looking for words. Two letters is
103
+ * the threshold: it admits "OK" and excludes every unit, separator and numeral.
104
+ *
105
+ * @param {string} text - The candidate, whitespace already normalized.
106
+ * @param {Set<string>} allowed - Literals the repository has justified.
107
+ * @returns {boolean} Whether it should have been localized.
108
+ */
109
+ function isProse(text, allowed) {
110
+ return (
111
+ /[A-Za-z]{2}/.test(text.replace(/&[a-zA-Z]+;|&#\d+;/g, " ")) &&
112
+ !allowed.has(text)
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Every user-visible literal a template leaves untranslated.
118
+ *
119
+ * @param {string} source - The template source.
120
+ * @param {object} [options]
121
+ * @param {Iterable<string>} [options.allow] - Literals that are deliberately
122
+ * not localization keys — a code sample shown as a placeholder, say. This is
123
+ * the escape hatch, not the rule: anything that is ordinary UI prose belongs
124
+ * in the localization file, and a repository states each entry with the
125
+ * reason it cannot be one.
126
+ * @returns {TemplateFinding[]} The findings, in the order they appear.
127
+ */
128
+ export function findHardcodedText(source, { allow = [] } = {}) {
129
+ const allowed = new Set(allow);
130
+ const stripped = stripNonProse(source);
131
+ const found = [];
132
+
133
+ /**
134
+ * Locate one literal in the **source**, not in the stripped text.
135
+ *
136
+ * The stripped copy has had substitutions of a different length, so a match
137
+ * index taken there cannot be carried across — the finding would name a
138
+ * position that drifts further from the truth the more Handlebars a file
139
+ * contains. Searching the source for the literal itself is exact, and
140
+ * counting occurrences keeps a literal that appears twice from reporting
141
+ * the same line twice.
142
+ *
143
+ * @param {string} needle - The literal exactly as the source spells it.
144
+ * @returns {{line?: number, column?: number}} Spreadable position fields.
145
+ */
146
+ const seen = new Map();
147
+ const at = (needle) => {
148
+ const occurrence = (seen.get(needle) ?? 0) + 1;
149
+ seen.set(needle, occurrence);
150
+ return positionOfLiteral(source, needle, occurrence);
151
+ };
152
+
153
+ for (const match of stripped.matchAll(/>([^<>]+)</g)) {
154
+ const text = match[1].replace(/\s+/g, " ").trim();
155
+ if (!isProse(text, allowed)) continue;
156
+ found.push({
157
+ ...at(match[1]),
158
+ severity: "error",
159
+ message: `hardcoded user-visible string: ${text}`,
160
+ });
161
+ }
162
+
163
+ for (const attr of VISIBLE_ATTRIBUTES) {
164
+ // `\b` alone would match `title` inside `data-title`, reporting one
165
+ // literal twice under two names; a hyphen is a word boundary to a
166
+ // regular expression and part of the attribute name to HTML.
167
+ const pattern = new RegExp(`(?<![\\w-])${attr}="([^"]*)"`, "g");
168
+ for (const match of stripped.matchAll(pattern)) {
169
+ const text = match[1].replace(/\s+/g, " ").trim();
170
+ if (!isProse(text, allowed)) continue;
171
+ found.push({
172
+ ...at(match[0]),
173
+ severity: "error",
174
+ message: `hardcoded user-visible string: ${attr}="${text}"`,
175
+ });
176
+ }
177
+ }
178
+
179
+ return found;
180
+ }
181
+
182
+ /**
183
+ * Whether the template compiles at all.
184
+ *
185
+ * Precompiling rather than compiling: the question is whether Handlebars can
186
+ * *parse* the source, and precompilation answers it without needing any of the
187
+ * helpers the template calls to exist.
188
+ *
189
+ * @param {string} source - The template source.
190
+ * @returns {TemplateFinding[]} One finding when it does not parse, else none.
191
+ */
192
+ export function findTemplateSyntaxErrors(source) {
193
+ try {
194
+ Handlebars.precompile(source);
195
+ return [];
196
+ } catch (err) {
197
+ // Handlebars states a position three different ways depending on which
198
+ // stage rejected the template, and populates `hash.loc` for almost
199
+ // none of them — so the line printed in its own message is the one that
200
+ // is usually there. Reading it back is not guesswork: it is Handlebars'
201
+ // answer, in the only place this error carries it.
202
+ const loc = err?.hash?.loc;
203
+ const stated = /Parse error on line (\d+)/.exec(String(err?.message));
204
+ const line =
205
+ loc?.first_line ??
206
+ err?.lineNumber ??
207
+ (stated ? Number(stated[1]) : undefined);
208
+ return [
209
+ {
210
+ ...(typeof line === "number" ? { line } : {}),
211
+ ...((
212
+ typeof line === "number" &&
213
+ typeof loc?.first_column === "number"
214
+ ) ?
215
+ { column: loc.first_column + 1 }
216
+ : {}),
217
+ severity: "error",
218
+ message: `template does not compile: ${
219
+ String(err).split("\n")[0]
220
+ }`,
221
+ },
222
+ ];
223
+ }
224
+ }
@@ -21,9 +21,43 @@
21
21
  * conventional build artifacts.
22
22
  * @property {string} langSources Glob for the localization files to check.
23
23
  * @property {string|null} langHelp Extra guidance printed after a failure.
24
+ * @property {string} langPrimary The localization file coverage is measured
25
+ * against — the one the package authors.
26
+ * @property {readonly string[]} langScripts Globs for the sources scanned
27
+ * for key references.
28
+ * @property {readonly string[]} langTemplates Globs for the templates scanned
29
+ * for references and for hardcoded text.
30
+ * @property {readonly string[]|null} langKeyRoots The key roots, when the
31
+ * package references one its file does not
32
+ * yet declare. `null` derives them.
33
+ * @property {string|null} langReferences Module to load a `references`
34
+ * function from, contributing the keys only
35
+ * this repository's conventions can find.
36
+ * @property {readonly string[]} langRetained Key prefixes exempt from the
37
+ * unreferenced advisory.
38
+ * @property {readonly string[]} langAllow Template literals that are
39
+ * deliberately not localization keys.
24
40
  * @property {string} envPrefix Prefix of the deploy environment variables.
25
41
  * @property {string} bundleEntry The bundle file Foundry loads, as the
26
42
  * manifest spells it. Derived from the package id.
43
+ * @property {string|null} compatibilityMinimum The Foundry floor the package
44
+ * claims, read from the shared configuration's top level.
45
+ * @property {string} systemId The system a world runs — the package
46
+ * itself for a system, its target for a module.
47
+ * @property {string|null} systemVersion That system's version, when the shared
48
+ * configuration stamps one.
49
+ * @property {string|null} containerImage Image override for every stage.
50
+ * @property {Readonly<Record<string, Readonly<{port: number|null, world: string|null, version: string|null}>>>} containerStages
51
+ * Container stages beyond the conventional four.
52
+ * @property {string} e2eStage Which stage the suite runs against.
53
+ * @property {Readonly<{run: readonly string[], open: readonly string[]|null}>|null} e2eSuite
54
+ * What to run against the served world; `null` when the repository has none.
55
+ * @property {Readonly<Record<string, Readonly<{script: string, recreate: boolean}>>>} e2eBuild
56
+ * Build targets the fast loop can produce, in declaration order.
57
+ * @property {Readonly<Record<string, string>>} e2eWorld Declared world identity.
58
+ * @property {Readonly<Record<string, string>>} e2eGm Declared GM credentials.
59
+ * @property {Readonly<Record<string, string>>} e2eDocuments Extra world
60
+ * collections, as collection → source directory.
27
61
  */
28
62
  /**
29
63
  * Resolve a package-build configuration from an already-loaded shared one.
@@ -133,6 +167,43 @@ export type PackageBuildConfig = {
133
167
  * Extra guidance printed after a failure.
134
168
  */
135
169
  langHelp: string | null;
170
+ /**
171
+ * The localization file coverage is measured
172
+ * against — the one the package authors.
173
+ */
174
+ langPrimary: string;
175
+ /**
176
+ * Globs for the sources scanned
177
+ * for key references.
178
+ */
179
+ langScripts: readonly string[];
180
+ /**
181
+ * Globs for the templates scanned
182
+ * for references and for hardcoded text.
183
+ */
184
+ langTemplates: readonly string[];
185
+ /**
186
+ * The key roots, when the
187
+ * package references one its file does not
188
+ * yet declare. `null` derives them.
189
+ */
190
+ langKeyRoots: readonly string[] | null;
191
+ /**
192
+ * Module to load a `references`
193
+ * function from, contributing the keys only
194
+ * this repository's conventions can find.
195
+ */
196
+ langReferences: string | null;
197
+ /**
198
+ * Key prefixes exempt from the
199
+ * unreferenced advisory.
200
+ */
201
+ langRetained: readonly string[];
202
+ /**
203
+ * Template literals that are
204
+ * deliberately not localization keys.
205
+ */
206
+ langAllow: readonly string[];
136
207
  /**
137
208
  * Prefix of the deploy environment variables.
138
209
  */
@@ -142,4 +213,62 @@ export type PackageBuildConfig = {
142
213
  * manifest spells it. Derived from the package id.
143
214
  */
144
215
  bundleEntry: string;
216
+ /**
217
+ * The Foundry floor the package
218
+ * claims, read from the shared configuration's top level.
219
+ */
220
+ compatibilityMinimum: string | null;
221
+ /**
222
+ * The system a world runs — the package
223
+ * itself for a system, its target for a module.
224
+ */
225
+ systemId: string;
226
+ /**
227
+ * That system's version, when the shared
228
+ * configuration stamps one.
229
+ */
230
+ systemVersion: string | null;
231
+ /**
232
+ * Image override for every stage.
233
+ */
234
+ containerImage: string | null;
235
+ /**
236
+ * Container stages beyond the conventional four.
237
+ */
238
+ containerStages: Readonly<Record<string, Readonly<{
239
+ port: number | null;
240
+ world: string | null;
241
+ version: string | null;
242
+ }>>>;
243
+ /**
244
+ * Which stage the suite runs against.
245
+ */
246
+ e2eStage: string;
247
+ /**
248
+ * What to run against the served world; `null` when the repository has none.
249
+ */
250
+ e2eSuite: Readonly<{
251
+ run: readonly string[];
252
+ open: readonly string[] | null;
253
+ }> | null;
254
+ /**
255
+ * Build targets the fast loop can produce, in declaration order.
256
+ */
257
+ e2eBuild: Readonly<Record<string, Readonly<{
258
+ script: string;
259
+ recreate: boolean;
260
+ }>>>;
261
+ /**
262
+ * Declared world identity.
263
+ */
264
+ e2eWorld: Readonly<Record<string, string>>;
265
+ /**
266
+ * Declared GM credentials.
267
+ */
268
+ e2eGm: Readonly<Record<string, string>>;
269
+ /**
270
+ * Extra world
271
+ * collections, as collection → source directory.
272
+ */
273
+ e2eDocuments: Readonly<Record<string, string>>;
145
274
  };