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