@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 ADDED
@@ -0,0 +1,25 @@
1
+ # License Information
2
+
3
+ `@heroiclands/content-build` is **source code only** and is licensed under
4
+ [GPL-3.0-or-later](https://www.gnu.org/licenses/gpl-3.0.html).
5
+
6
+ Unlike the Song of Heroic Lands system repository, this package carries no
7
+ creative content, so the CC-BY-SA-4.0 half of that project's dual licence does
8
+ not apply here. The content trees this toolchain _compiles_ are licensed by the
9
+ repositories that hold them, and their licences are unaffected by this one.
10
+
11
+ ## Source Code License
12
+
13
+ Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
14
+
15
+ This program is free software: you can redistribute it and/or modify it under
16
+ the terms of the GNU General Public License as published by the Free Software
17
+ Foundation, either version 3 of the License, or (at your option) any later
18
+ version.
19
+
20
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY
21
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
22
+ PARTICULAR PURPOSE. See the GNU General Public License for more details.
23
+
24
+ You should have received a copy of the GNU General Public License along with
25
+ this program. If not, see <https://www.gnu.org/licenses/>.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # @heroiclands/package-build
2
+
3
+ The shared toolchain for building and shipping a HeroicLands **Foundry
4
+ package** — the parts Foundry loads whether or not the package ships any
5
+ content.
6
+
7
+ It is the counterpart to
8
+ [`@heroiclands/content-build`](https://github.com/HeroicLands/content-build), and
9
+ the two split by **input**:
10
+
11
+ | Package | Reads | Produces |
12
+ | --------------- | ------------------------------------------------------------ | -------------------------------------------------------------- |
13
+ | `content-build` | `assets/content/**` | compendium packs, site content, link manifest |
14
+ | `package-build` | `lang/`, `styles/`, `src/`, `assets/`, the manifest template | `system.json` / `module.json`, styles, bundle, release archive |
15
+
16
+ A module uses either, or both. An adventure module that ships only notes needs
17
+ no bundler; a variant module that ships only behavior needs no Markdown
18
+ pipeline. The coupling runs one way — `package-build` asks `content-build` for
19
+ the compiled `packs[]` block, never the reverse.
20
+
21
+ ## Install
22
+
23
+ ```
24
+ npm install -D @heroiclands/package-build
25
+ ```
26
+
27
+ ## What it covers
28
+
29
+ The whole of assemble → validate → ship, one subpath each:
30
+
31
+ - **`manifest`** — the Foundry package manifest, `system.json` or `module.json`:
32
+ read the repository's template, stamp the version and the four release
33
+ addresses, write it into the stage. The artifact is inferred from the
34
+ template's name, and every address is derived from `package.json`'s
35
+ `repository` — nothing is transcribed.
36
+ - **`stage`** — assembling the build stage and clearing it away again. A listed
37
+ asset path that does not exist **fails the build** rather than shipping a
38
+ package that quietly lacks its localization or its templates, and the whole
39
+ list is checked before anything is copied, so a bad list leaves no
40
+ half-populated stage.
41
+ - **`lang`** — what a shippable Foundry localization file must satisfy: it
42
+ parses, its top level is an object, no key is both a leaf and a dotted prefix
43
+ of another, placeholders are single-braced, and key segments carry no data.
44
+ - **`bundle`** — whether the manifest agrees with the file it points at.
45
+ Declared under `"esmodules"` the bundle must parse as a module; declared under
46
+ `"scripts"` it must declare **nothing** at top level, because every top-level
47
+ declaration in a classic script is a global lexical binding and one colliding
48
+ with a non-configurable `window` property throws at parse time.
49
+ - **`release`** — the two assets a GitHub Release carries, `<artifact>.zip` and
50
+ the manifest beside it. Waits for the archive to be _written_, not merely
51
+ finalized.
52
+ - **`deploy`** — installing a staged package into a Foundry data directory, over
53
+ a local copy or SFTP. Always a staged, atomic swap: a running Foundry holds
54
+ its LevelDB packs open, and replacing them in place leaves a directory LevelDB
55
+ "repairs" to zero.
56
+ - **`text`** — locating a literal inside a file, so a finding names the line and
57
+ column it is about.
58
+
59
+ ## Design
60
+
61
+ **The rules are pure, and I/O is confined to functions named for it.** A rule
62
+ takes source text or data and returns findings or values; discovery and
63
+ reporting stay with the caller. Where a step genuinely has to touch disk or a
64
+ network it is an export named for what it does — `writeFoundryManifest`,
65
+ `stageAssets`, `packRelease`, `deployStage`.
66
+
67
+ That is what lets one rule set serve a `lint` script, a build step and a unit
68
+ test without any of them agreeing on how files are found or how findings are
69
+ printed — and it is what makes the rules testable at all, which the scripts they
70
+ were extracted from were not: each ran its work at import time and exported
71
+ nothing.
72
+
73
+ Findings carry the fields the shared diagnostic format takes (`line`, `column`,
74
+ `severity`, `message`) but never `file`, which only the caller knows. The format
75
+ itself is owned by `@heroiclands/content-build`'s `engine/diagnostics`, and is
76
+ not restated here.
77
+
78
+ ```js
79
+ import { validateLangSource } from "@heroiclands/package-build/lang";
80
+
81
+ for (const file of globSync("lang/*.json")) {
82
+ for (const finding of validateLangSource(readFileSync(file, "utf8"))) {
83
+ reportDiagnostic({ file, ...finding });
84
+ }
85
+ }
86
+ ```
87
+
88
+ ## Tests
89
+
90
+ ```
91
+ npm test
92
+ ```
93
+
94
+ Plain `vitest`, no setup file and no aliases: everything here is ESM over Node
95
+ built-ins and three dependencies, and a harness that offered a Foundry global
96
+ would let something reach for one.
97
+
98
+ `tests/dependencies-are-declared.test.ts` is the guard an extraction most needs
99
+ — every bare specifier in a shipped file must be a builtin, this package, or a
100
+ declared `dependency`. Inside a workspace a missing declaration is invisible;
101
+ installed from npm it fails on the first import.
102
+
103
+ ## Licence
104
+
105
+ GPL-3.0-or-later.
package/bundle.mjs ADDED
@@ -0,0 +1,250 @@
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 code bundle, and the one way a manifest can disagree with it.
16
+ *
17
+ * A package that ships behavior declares its entry point in the manifest, and
18
+ * **which key it uses decides how the browser parses the file**:
19
+ *
20
+ * - Under `"esmodules"` the file is an ES module. Every top-level `const`,
21
+ * `let`, `class` and `function` is module-scoped — private to the bundle,
22
+ * colliding with nothing.
23
+ * - Under `"scripts"` the file is a *classic script*. Those same declarations
24
+ * become **global lexical bindings**, and one whose name matches a
25
+ * non-configurable own property of `window` throws
26
+ * `SyntaxError: Identifier 'x' has already been declared` at **parse time** —
27
+ * before a single line of the package runs.
28
+ *
29
+ * That is not hypothetical. A bundle that inlines `@codemirror/view` carries
30
+ * `const chrome`, and `style-mod` carries `const top`; `window.chrome` is
31
+ * `configurable: false` and `window.top` is `[Unforgeable]`, so under
32
+ * `"scripts"` either one bricks the whole package on load. Shipping `"scripts"`
33
+ * is exactly how SoHL v0.8.0 broke. A minified bundle escapes it only by
34
+ * renaming the identifiers, which is luck rather than a property.
35
+ *
36
+ * So the check is not "is the manifest key right" — it is **does the manifest
37
+ * agree with the file it points at**. Declared as a module, the bundle must
38
+ * parse as one. Declared as a script, it must declare *nothing* at top level.
39
+ * That second invariant is list-free: it needs no catalogue of browser globals
40
+ * and holds whatever identifiers a future dependency introduces.
41
+ *
42
+ * The rules are pure functions over source text. Reading the stage is the
43
+ * caller's job.
44
+ *
45
+ * @module
46
+ */
47
+
48
+ import { parse } from "acorn";
49
+
50
+ /**
51
+ * The names a top-level statement would declare in global scope.
52
+ *
53
+ * Only declaration forms matter: an expression statement or a call declares
54
+ * nothing. Destructuring patterns are walked, so `const { a, b } = …` reports
55
+ * both names — a bundler emits those routinely, and missing them would let the
56
+ * check pass a bundle that does collide.
57
+ *
58
+ * @param {object} node - A top-level `Program.body` entry.
59
+ * @returns {string[]} Declared identifier names, empty when it declares none.
60
+ */
61
+ export function declaredGlobals(node) {
62
+ /**
63
+ * @param {any} pattern - A binding pattern.
64
+ * @param {string[]} out - Names collected so far.
65
+ * @returns {string[]} `out`.
66
+ */
67
+ function namesIn(pattern, out) {
68
+ if (!pattern) return out;
69
+ switch (pattern.type) {
70
+ case "Identifier":
71
+ out.push(pattern.name);
72
+ break;
73
+ case "ObjectPattern":
74
+ for (const p of pattern.properties)
75
+ namesIn(
76
+ p.type === "RestElement" ? p.argument : p.value,
77
+ out,
78
+ );
79
+ break;
80
+ case "ArrayPattern":
81
+ for (const e of pattern.elements) namesIn(e, out);
82
+ break;
83
+ case "AssignmentPattern":
84
+ namesIn(pattern.left, out);
85
+ break;
86
+ case "RestElement":
87
+ namesIn(pattern.argument, out);
88
+ break;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ switch (node.type) {
94
+ case "VariableDeclaration": {
95
+ const out = [];
96
+ for (const d of node.declarations) namesIn(d.id, out);
97
+ return out;
98
+ }
99
+ case "FunctionDeclaration":
100
+ case "ClassDeclaration":
101
+ return node.id ? [node.id.name] : [];
102
+ default:
103
+ return [];
104
+ }
105
+ }
106
+
107
+ /**
108
+ * How a manifest declares an entry file.
109
+ *
110
+ * @param {object} manifest - The parsed manifest.
111
+ * @param {string} entry - The entry file's name, as the manifest spells it.
112
+ * @returns {"esmodules"|"scripts"|"both"|"neither"} Where it is declared.
113
+ */
114
+ export function entryDeclaration(manifest, entry) {
115
+ const asModule = (manifest?.esmodules ?? []).includes(entry);
116
+ const asScript = (manifest?.scripts ?? []).includes(entry);
117
+ if (asModule && asScript) return "both";
118
+ if (asModule) return "esmodules";
119
+ if (asScript) return "scripts";
120
+ return "neither";
121
+ }
122
+
123
+ /**
124
+ * Every top-level declaration a source would create in global scope.
125
+ *
126
+ * Parses as a **classic script**, which is the only parse under which the
127
+ * question means anything.
128
+ *
129
+ * @param {string} source - The bundle's source text.
130
+ * @returns {Array<{name: string, line: number, kind: string}>} The declarations.
131
+ * @throws {SyntaxError} When the source does not parse as a script.
132
+ */
133
+ export function globalDeclarations(source) {
134
+ const program = parse(source, {
135
+ ecmaVersion: "latest",
136
+ sourceType: "script",
137
+ locations: true,
138
+ });
139
+ const found = [];
140
+ for (const node of program.body) {
141
+ for (const name of declaredGlobals(node)) {
142
+ found.push({ name, line: node.loc.start.line, kind: node.type });
143
+ }
144
+ }
145
+ return found;
146
+ }
147
+
148
+ /**
149
+ * Check that a manifest and the bundle it points at agree.
150
+ *
151
+ * @param {object} opts
152
+ * @param {object} opts.manifest - The parsed manifest.
153
+ * @param {string} opts.source - The bundle's source text.
154
+ * @param {string} opts.entry - The entry file's name, as the manifest spells it.
155
+ * @param {string} [opts.manifestName] - What to call the manifest in a message.
156
+ * @returns {{findings: Array<{line?: number, severity: "error", message: string}>,
157
+ * declaredAs: "esmodules"|"scripts"|"both"|"neither"}} The findings, empty
158
+ * when the two agree, and how the entry was declared.
159
+ */
160
+ export function checkBundleLoading({
161
+ manifest,
162
+ source,
163
+ entry,
164
+ manifestName = "the manifest",
165
+ }) {
166
+ const declaredAs = entryDeclaration(manifest, entry);
167
+
168
+ if (declaredAs === "both") {
169
+ return {
170
+ declaredAs,
171
+ findings: [
172
+ {
173
+ severity: "error",
174
+ message:
175
+ `${manifestName} lists ${entry} under both "esmodules" and ` +
176
+ `"scripts", so Foundry would load the bundle twice. List it ` +
177
+ `under "esmodules" only.`,
178
+ },
179
+ ],
180
+ };
181
+ }
182
+
183
+ if (declaredAs === "neither") {
184
+ return {
185
+ declaredAs,
186
+ findings: [
187
+ {
188
+ severity: "error",
189
+ message:
190
+ `${manifestName} declares ${entry} under neither "esmodules" ` +
191
+ `nor "scripts", so Foundry would never load it. List it under ` +
192
+ `"esmodules".`,
193
+ },
194
+ ],
195
+ };
196
+ }
197
+
198
+ if (declaredAs === "esmodules") {
199
+ // Declared a module, so it must be one. A bundle that only parses as a
200
+ // script would fail at load with a message about whichever `import`
201
+ // statement came first, naming nothing about the manifest.
202
+ try {
203
+ parse(source, { ecmaVersion: "latest", sourceType: "module" });
204
+ } catch (err) {
205
+ return {
206
+ declaredAs,
207
+ findings: [
208
+ {
209
+ severity: "error",
210
+ message:
211
+ `${entry} is declared under "esmodules" but does not ` +
212
+ `parse as an ES module: ${err.message}`,
213
+ },
214
+ ],
215
+ };
216
+ }
217
+ return { declaredAs, findings: [] };
218
+ }
219
+
220
+ // Declared a classic script: every top-level declaration becomes global.
221
+ let globals;
222
+ try {
223
+ globals = globalDeclarations(source);
224
+ } catch (err) {
225
+ return {
226
+ declaredAs,
227
+ findings: [
228
+ {
229
+ severity: "error",
230
+ message:
231
+ `${entry} is declared under "scripts" but does not parse ` +
232
+ `as a classic script: ${err.message}`,
233
+ },
234
+ ],
235
+ };
236
+ }
237
+
238
+ return {
239
+ declaredAs,
240
+ findings: globals.map(({ name, line, kind }) => ({
241
+ line,
242
+ severity: "error",
243
+ message:
244
+ `${kind} \`${name}\` is declared at global scope; under ` +
245
+ `"scripts" that is a global lexical binding, and one colliding ` +
246
+ `with a non-configurable window property throws at parse time ` +
247
+ `and breaks the whole package`,
248
+ })),
249
+ };
250
+ }