@heroiclands/package-build 0.2.1 → 0.4.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/CHANGELOG.md +92 -0
- package/README.md +105 -5
- package/bin/package-build.mjs +150 -14
- package/bin/report.mjs +108 -0
- package/config.mjs +105 -0
- package/manifest.mjs +152 -96
- package/package.json +2 -2
- package/types/config.d.mts +35 -0
- package/types/manifest.d.mts +48 -52
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,97 @@
|
|
|
1
1
|
# @heroiclands/package-build
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2c2fc37: **`package-build bundle check` — the last capability that had no command.**
|
|
8
|
+
|
|
9
|
+
The bundle-loading check was exported as a library function and reachable no
|
|
10
|
+
other way, so a consumer that wanted it had to write the script the command line
|
|
11
|
+
exists to remove: read the manifest, read the bundle, call the function, decide
|
|
12
|
+
how to print findings, choose an exit code. It now runs from configuration like
|
|
13
|
+
every other job.
|
|
14
|
+
|
|
15
|
+
It catches three ways a package builds successfully and still does not load,
|
|
16
|
+
none of which a bundler can see, because each is a disagreement between two
|
|
17
|
+
files rather than a fault in either:
|
|
18
|
+
|
|
19
|
+
| The manifest says | What Foundry does |
|
|
20
|
+
| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
21
|
+
| the entry under both `esmodules` and `scripts` | loads the bundle twice |
|
|
22
|
+
| the entry under neither | never loads it at all |
|
|
23
|
+
| the entry under `esmodules`, but the file only parses as a classic script | fails at load, naming whichever `import` came first and nothing about the manifest |
|
|
24
|
+
|
|
25
|
+
Both files are read from the stage, because the stage is what ships.
|
|
26
|
+
|
|
27
|
+
**The entry is derived, not stated.** `packageBuild.bundle.entry` defaults to
|
|
28
|
+
`<packageId>.mjs`, which is already derived from `package.json` `name`; a
|
|
29
|
+
repository states it only when its bundler emits something else. It is
|
|
30
|
+
deliberately _not_ read back out of the generated manifest — a value taken from
|
|
31
|
+
there would agree with itself by construction, and the check's whole question is
|
|
32
|
+
whether the manifest declares this file the way Foundry needs it.
|
|
33
|
+
|
|
34
|
+
**Reporting is now decided once.** `lang check` had the diagnostic contract —
|
|
35
|
+
`file:line:column: severity: message`, the path starting the line, a field
|
|
36
|
+
dropped rather than guessed — spelled out inline in its handler. Both commands
|
|
37
|
+
now report through one seam that maps findings onto the format
|
|
38
|
+
`@heroiclands/content-build` already owns, so the two packages cannot drift into
|
|
39
|
+
two nearly-identical formats. `lang check`'s output is unchanged.
|
|
40
|
+
|
|
41
|
+
**The command surface has a stated shape.** A capability with a single operation
|
|
42
|
+
is a bare command (`clean`, `assets`, `manifest`, `release`, `deploy <stage>`);
|
|
43
|
+
one with more than a single operation takes a positional action, so a second can
|
|
44
|
+
be added without renaming the first (`lang check`, `bundle check`).
|
|
45
|
+
|
|
46
|
+
Closes #12
|
|
47
|
+
|
|
48
|
+
## 0.3.0
|
|
49
|
+
|
|
50
|
+
### Minor Changes
|
|
51
|
+
|
|
52
|
+
- 0a2ef1e: **The Foundry manifest is generated from configuration. The template is
|
|
53
|
+
retired.**
|
|
54
|
+
|
|
55
|
+
`package-build manifest` writes `system.json` / `module.json` from
|
|
56
|
+
`packageBuild.manifest` plus the facts the build already holds. There is no
|
|
57
|
+
`assets/templates/*.template.json`, and the template-reading path is removed
|
|
58
|
+
rather than left as a fallback: `writeFoundryManifest`, `stampManifest` and
|
|
59
|
+
`artifactFromTemplate` are gone, replaced by `buildManifest`, `writeManifest`
|
|
60
|
+
and `manifestPacks`.
|
|
61
|
+
|
|
62
|
+
The manifest was the one build input still hand-authored JSON, per repository,
|
|
63
|
+
with no schema and nothing checking it — and it declared facts the configuration
|
|
64
|
+
already declared. SoHL's pack list was written twice, in two formats, with
|
|
65
|
+
nothing checking the pairs agreed; `sohl-kethira-basic` hand-maintained its whole
|
|
66
|
+
`module.json`, and its `download` named an older version than the module claimed.
|
|
67
|
+
|
|
68
|
+
Three kinds of key end up in the result:
|
|
69
|
+
|
|
70
|
+
- **Declared** — `packageBuild.manifest`, emitted unchanged, so a key Foundry
|
|
71
|
+
adds in a later version needs no release of this package. The block is
|
|
72
|
+
deliberately not key-checked; pass-through and unknown-key checking cannot
|
|
73
|
+
coexist, which is why it is its own block rather than spread across
|
|
74
|
+
`packageBuild:` where the keys around it are still checked.
|
|
75
|
+
- **Derived** — `id`, `version`, `url`, `bugs`, `manifest`, `download`,
|
|
76
|
+
`compatibility`, `relationships`, `packs`. Declaring one is an **error**
|
|
77
|
+
naming the key and where the value actually comes from, not an override: an
|
|
78
|
+
authored copy would be silently overwritten and the two would disagree with
|
|
79
|
+
nothing to say so.
|
|
80
|
+
- **Computed** — namespaced `flags` from a module named in
|
|
81
|
+
`packageBuild.manifestFlags`, merged over any declared. That is for a value a
|
|
82
|
+
repository must work out rather than state — SoHL's credits `@UUID` only
|
|
83
|
+
exists once the content tree has been walked.
|
|
84
|
+
|
|
85
|
+
`packs` comes from the **one** pack list at the top level of
|
|
86
|
+
`content-build.config.yaml`, with companions flattened in. Give each pack the
|
|
87
|
+
`label` Foundry should show; everything else is derived.
|
|
88
|
+
|
|
89
|
+
Requires `@heroiclands/content-build` **1.0.0**, which moved `compatibility` and
|
|
90
|
+
`relationships` to the top level (content-build#50).
|
|
91
|
+
|
|
92
|
+
Verified against SoHL's real package: the generated manifest is **byte-identical**
|
|
93
|
+
to what its template pipeline produces today, all 24 keys, key order included.
|
|
94
|
+
|
|
3
95
|
## 0.2.1
|
|
4
96
|
|
|
5
97
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -95,8 +95,82 @@ packageBuild:
|
|
|
95
95
|
deploy:
|
|
96
96
|
# Prefix of the shared SFTP override variables. Default `SOHL`.
|
|
97
97
|
envPrefix: SOHL
|
|
98
|
+
|
|
99
|
+
bundle:
|
|
100
|
+
# The file Foundry loads, as the manifest spells it, relative to the stage.
|
|
101
|
+
# Defaults to `<packageId>.mjs`; state it only when the bundler emits
|
|
102
|
+
# something else. Deliberately not read back out of the manifest — a value
|
|
103
|
+
# taken from there would agree with itself by construction, and `bundle
|
|
104
|
+
# check` asks whether the manifest declares this file correctly.
|
|
105
|
+
entry: sohl.mjs
|
|
106
|
+
|
|
107
|
+
# Optional. A module exporting `flags(config)` returning namespaced Foundry
|
|
108
|
+
# flags the repository has to *compute* — an address that only exists once
|
|
109
|
+
# the content tree has been walked, say. Merged over any declared below.
|
|
110
|
+
manifestFlags: ./utils/manifest-flags.mjs
|
|
111
|
+
|
|
112
|
+
# The Foundry package manifest. Emitted as declared, so a key Foundry adds in
|
|
113
|
+
# a later version needs no release of this package.
|
|
114
|
+
manifest:
|
|
115
|
+
title: Song of Heroic Lands
|
|
116
|
+
description: <p>…</p>
|
|
117
|
+
license: LICENSE.md
|
|
118
|
+
readme: README.md
|
|
119
|
+
authors:
|
|
120
|
+
- { name: Toasty, discord: "toasty#8538" }
|
|
121
|
+
esmodules: [sohl.js]
|
|
122
|
+
styles: [css/sohl.css]
|
|
123
|
+
languages:
|
|
124
|
+
- { lang: en, name: English, path: lang/en.json }
|
|
125
|
+
documentTypes:
|
|
126
|
+
Item:
|
|
127
|
+
skill: { htmlFields: [notes, docHtml] }
|
|
128
|
+
packFolders:
|
|
129
|
+
- name: Song of Heroic Lands
|
|
130
|
+
sorting: m
|
|
131
|
+
color: "#094fcb"
|
|
132
|
+
packs: [items, journals, actors, macros, scenes, adventures]
|
|
133
|
+
media:
|
|
134
|
+
- { type: logo, url: systems/sohl/assets/ui/logo.webp }
|
|
135
|
+
socket: true
|
|
136
|
+
grid: { distance: 5, units: ft }
|
|
137
|
+
primaryTokenAttribute: health
|
|
98
138
|
```
|
|
99
139
|
|
|
140
|
+
### The manifest is generated, not stamped
|
|
141
|
+
|
|
142
|
+
`package-build manifest` writes `system.json` / `module.json` into the stage.
|
|
143
|
+
**There is no template file.** A manifest used to be hand-authored JSON that the
|
|
144
|
+
build stamped a few fields into — the one build input still written by hand, per
|
|
145
|
+
repository, with no schema and nothing checking it. It also declared facts the
|
|
146
|
+
configuration already declared: the pack list twice, in two formats, with
|
|
147
|
+
nothing checking the pairs agreed.
|
|
148
|
+
|
|
149
|
+
Three kinds of key end up in the result:
|
|
150
|
+
|
|
151
|
+
| Kind | Where it comes from |
|
|
152
|
+
| ------------ | ------------------------------------------------------------------------------------------------- |
|
|
153
|
+
| **Declared** | `packageBuild.manifest`, emitted unchanged |
|
|
154
|
+
| **Derived** | `id`, `version`, `url`, `bugs`, `manifest`, `download`, `compatibility`, `relationships`, `packs` |
|
|
155
|
+
| **Computed** | namespaced `flags` from `manifestFlags`, merged over any declared |
|
|
156
|
+
|
|
157
|
+
**Declaring a derived key is an error, not an override.** An authored `version`
|
|
158
|
+
would look authoritative, sit there unread, and disagree with the shipped
|
|
159
|
+
package forever; the build says so, naming the key and where the value actually
|
|
160
|
+
comes from.
|
|
161
|
+
|
|
162
|
+
`packs` is derived from the **one** pack list at the top level of
|
|
163
|
+
`content-build.config.yaml` — each entry's `label`, `type`, `name` and
|
|
164
|
+
`private`, plus a `system` from `stats.systemId` and a `path` of
|
|
165
|
+
`packs/<name>`. Companions are flattened in, because Foundry sees no difference:
|
|
166
|
+
a companion is only a pack written by another pass rather than one of its own.
|
|
167
|
+
Give each pack the `label` you want Foundry to show.
|
|
168
|
+
|
|
169
|
+
`compatibility` and `relationships` are read from the **top level** of the
|
|
170
|
+
shared configuration, not from this section — content-build consumes them
|
|
171
|
+
(`supportedCoreVersion`, and a module'''s `stats.systemVersion`) and the
|
|
172
|
+
dependency runs one way.
|
|
173
|
+
|
|
100
174
|
**Why one file and not two.** Two of the values this package needs —
|
|
101
175
|
`packageKind` and `foundryPackage` — are already declared for `content-build`. A
|
|
102
176
|
second config file would restate them, which is two places for one fact; that is
|
|
@@ -110,18 +184,21 @@ other's schema — they split by input, and the dependency runs one way.
|
|
|
110
184
|
|
|
111
185
|
**What is derived, not stated:**
|
|
112
186
|
|
|
113
|
-
| Field | Derived from
|
|
114
|
-
| -------------------------- |
|
|
115
|
-
| the repository root | the configuration file's own location
|
|
116
|
-
| `packageKind`, `packageId` | the shared configuration's top level
|
|
117
|
-
| the release artifact | `packageKind` — a system ships `system.json`, a module `module.json`
|
|
187
|
+
| Field | Derived from |
|
|
188
|
+
| -------------------------- | --------------------------------------------------------------------------- |
|
|
189
|
+
| the repository root | the configuration file's own location |
|
|
190
|
+
| `packageKind`, `packageId` | the shared configuration's top level |
|
|
191
|
+
| the release artifact | `packageKind` — a system ships `system.json`, a module `module.json` |
|
|
192
|
+
| the bundle entry | `packageId` — `<id>.mjs`, unless `packageBuild.bundle.entry` says otherwise |
|
|
118
193
|
|
|
119
194
|
## Command line
|
|
120
195
|
|
|
121
196
|
```
|
|
122
197
|
npx package-build clean [--distclean]
|
|
123
198
|
npx package-build assets
|
|
199
|
+
npx package-build manifest
|
|
124
200
|
npx package-build lang check
|
|
201
|
+
npx package-build bundle check
|
|
125
202
|
npx package-build release
|
|
126
203
|
npx package-build deploy <stage>
|
|
127
204
|
```
|
|
@@ -134,11 +211,34 @@ Wrapped as npm scripts — SoHL spells them:
|
|
|
134
211
|
"distclean": "package-build clean --distclean",
|
|
135
212
|
"build:assets": "package-build assets",
|
|
136
213
|
"lint:lang": "package-build lang check",
|
|
214
|
+
"lint:bundle-globals": "package-build bundle check",
|
|
137
215
|
"build:pack-release": "package-build release",
|
|
138
216
|
"push:qa": "package-build deploy qa"
|
|
139
217
|
}
|
|
140
218
|
```
|
|
141
219
|
|
|
220
|
+
**The shape of the surface.** A capability with a single operation is a bare
|
|
221
|
+
command (`clean`, `assets`, `manifest`, `release`, `deploy <stage>`); one with
|
|
222
|
+
more than a single operation takes a positional action, so a second can be added
|
|
223
|
+
without renaming the first (`lang check`, `bundle check`). Flat `lang:check`
|
|
224
|
+
names would make every operation a new top-level command and hide which ones
|
|
225
|
+
belong together.
|
|
226
|
+
|
|
227
|
+
**What `bundle check` checks.** Three ways a package builds successfully and
|
|
228
|
+
still does not load, none of which a bundler can see, because each is a
|
|
229
|
+
disagreement between two files rather than a fault in either:
|
|
230
|
+
|
|
231
|
+
| The manifest says | What Foundry does |
|
|
232
|
+
| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
233
|
+
| the entry under both `esmodules` and `scripts` | loads the bundle twice |
|
|
234
|
+
| the entry under neither | never loads it at all |
|
|
235
|
+
| the entry under `esmodules`, but the file only parses as a classic script | fails at load, naming whichever `import` came first and nothing about the manifest |
|
|
236
|
+
|
|
237
|
+
It reads both files from the stage, because the stage is what ships — checking
|
|
238
|
+
sources would answer for a package nobody installs. Findings are emitted as
|
|
239
|
+
`file:line:column: severity: message`, and the command exits non-zero when any
|
|
240
|
+
of them is an error.
|
|
241
|
+
|
|
142
242
|
**Why the CLI exists.** This package was library-only, so every consuming
|
|
143
243
|
repository wrote a wrapper script per job — six of them in the SoHL repository,
|
|
144
244
|
441 lines that between them contained no logic. `clean.mjs` was 47 lines that
|
package/bin/package-build.mjs
CHANGED
|
@@ -34,6 +34,13 @@
|
|
|
34
34
|
* stay import-safe, so a consuming repository's build — or a test — can call
|
|
35
35
|
* them without any of it happening.
|
|
36
36
|
*
|
|
37
|
+
* **The shape of the command surface.** A capability with a single operation is
|
|
38
|
+
* a bare command (`clean`, `assets`, `manifest`, `release`, `deploy <stage>`);
|
|
39
|
+
* one with more than a single operation takes a positional action, so it can
|
|
40
|
+
* grow another without renaming the first (`lang check`, `bundle check`). The
|
|
41
|
+
* alternative — flat `lang:check`-style names — makes the second operation a
|
|
42
|
+
* new top-level command and the relationship between them invisible.
|
|
43
|
+
*
|
|
37
44
|
* The side effects that need *configuration* live inside the command handlers,
|
|
38
45
|
* never at module scope, so `--version` and `--help` answer in a directory with
|
|
39
46
|
* no configuration at all. Running an actual command still resolves it, and
|
|
@@ -42,7 +49,9 @@
|
|
|
42
49
|
* Usage:
|
|
43
50
|
* npx package-build clean [--distclean]
|
|
44
51
|
* npx package-build assets
|
|
52
|
+
* npx package-build manifest
|
|
45
53
|
* npx package-build lang check
|
|
54
|
+
* npx package-build bundle check
|
|
46
55
|
* npx package-build release
|
|
47
56
|
* npx package-build deploy <stage>
|
|
48
57
|
*
|
|
@@ -50,6 +59,7 @@
|
|
|
50
59
|
* npm run clean // → … clean
|
|
51
60
|
* npm run build:assets // → … assets
|
|
52
61
|
* npm run lint:lang // → … lang check
|
|
62
|
+
* npm run lint:bundle-globals // → … bundle check
|
|
53
63
|
* npm run build:pack-release // → … release
|
|
54
64
|
* npm run push:qa // → … deploy qa
|
|
55
65
|
*/
|
|
@@ -62,10 +72,14 @@ import yargs from "yargs";
|
|
|
62
72
|
import { hideBin } from "yargs/helpers";
|
|
63
73
|
|
|
64
74
|
import { loadPackageBuildConfig } from "../config.mjs";
|
|
75
|
+
import { loadPackConfig } from "@heroiclands/content-build/engine/pack-config";
|
|
65
76
|
import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
|
|
66
77
|
import { validateLangSource } from "../lang.mjs";
|
|
78
|
+
import { checkBundleLoading } from "../bundle.mjs";
|
|
67
79
|
import { packRelease } from "../release.mjs";
|
|
80
|
+
import { writeManifest } from "../manifest.mjs";
|
|
68
81
|
import { deployStage } from "../deploy.mjs";
|
|
82
|
+
import { reportFindings } from "./report.mjs";
|
|
69
83
|
|
|
70
84
|
/**
|
|
71
85
|
* This package's own version, for `--version`.
|
|
@@ -212,6 +226,70 @@ function assetsCommand() {
|
|
|
212
226
|
};
|
|
213
227
|
}
|
|
214
228
|
|
|
229
|
+
/**
|
|
230
|
+
* `manifest` — generate `system.json` / `module.json` into the build stage.
|
|
231
|
+
*
|
|
232
|
+
* There is no template to read. Everything the manifest needs is either
|
|
233
|
+
* declared in `packageBuild.manifest`, derived from configuration this
|
|
234
|
+
* repository already carries, or computed by a module the repository names in
|
|
235
|
+
* `packageBuild.manifestFlags` — for a namespaced flag it has to work out, such
|
|
236
|
+
* as the compendium address of a document that only exists once the content
|
|
237
|
+
* tree has been walked.
|
|
238
|
+
*
|
|
239
|
+
* @returns {object} The yargs command module.
|
|
240
|
+
*/
|
|
241
|
+
function manifestCommand() {
|
|
242
|
+
return {
|
|
243
|
+
command: "manifest",
|
|
244
|
+
describe: "Generate the Foundry package manifest",
|
|
245
|
+
builder: (y) => y,
|
|
246
|
+
handler: handler(async () => {
|
|
247
|
+
const config = loadPackageBuildConfig();
|
|
248
|
+
const shared = loadPackConfig();
|
|
249
|
+
const packageJson = JSON.parse(
|
|
250
|
+
fs.readFileSync(
|
|
251
|
+
path.join(config.rootDir, "package.json"),
|
|
252
|
+
"utf8",
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
let flags;
|
|
257
|
+
if (config.manifestFlags) {
|
|
258
|
+
const module = await import(
|
|
259
|
+
`file://${config.manifestFlags}`
|
|
260
|
+
).catch((err) =>
|
|
261
|
+
die(
|
|
262
|
+
`cannot load \`packageBuild.manifestFlags\` ` +
|
|
263
|
+
`(${config.manifestFlags}): ${err.message}`,
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
if (typeof module.flags !== "function") {
|
|
267
|
+
die(
|
|
268
|
+
`\`packageBuild.manifestFlags\` ` +
|
|
269
|
+
`(${config.manifestFlags}) exports no \`flags\` ` +
|
|
270
|
+
`function. It must export ` +
|
|
271
|
+
`\`flags(config) -> Record<string, object>\`.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
flags = await module.flags(shared);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const { path: written, manifest } = await writeManifest({
|
|
278
|
+
config: shared,
|
|
279
|
+
packageJson,
|
|
280
|
+
artifact: config.artifact,
|
|
281
|
+
outDir: path.join(config.rootDir, config.stageDir),
|
|
282
|
+
flags,
|
|
283
|
+
});
|
|
284
|
+
console.log(
|
|
285
|
+
`✅ Wrote ${path.relative(config.rootDir, written)} ` +
|
|
286
|
+
`(${Object.keys(manifest).length} keys, ` +
|
|
287
|
+
`${manifest.packs.length} packs).`,
|
|
288
|
+
);
|
|
289
|
+
}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
215
293
|
/**
|
|
216
294
|
* `lang check` — verify every localization file survives `expandObject`.
|
|
217
295
|
*
|
|
@@ -245,20 +323,10 @@ function langCommand() {
|
|
|
245
323
|
|
|
246
324
|
let total = 0;
|
|
247
325
|
for (const file of files.sort()) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
)
|
|
252
|
-
total++;
|
|
253
|
-
// The diagnostics contract: the path starts the line, and a
|
|
254
|
-
// field is dropped rather than guessed.
|
|
255
|
-
const at = [relative, finding.line, finding.column]
|
|
256
|
-
.filter((part) => part !== undefined && part !== null)
|
|
257
|
-
.join(":");
|
|
258
|
-
console.error(
|
|
259
|
-
`${at}: ${finding.severity ?? "error"}: ${finding.message}`,
|
|
260
|
-
);
|
|
261
|
-
}
|
|
326
|
+
total += reportFindings(
|
|
327
|
+
validateLangSource(fs.readFileSync(file, "utf8")),
|
|
328
|
+
{ file: path.relative(config.rootDir, file) },
|
|
329
|
+
);
|
|
262
330
|
}
|
|
263
331
|
|
|
264
332
|
if (total) {
|
|
@@ -273,6 +341,72 @@ function langCommand() {
|
|
|
273
341
|
};
|
|
274
342
|
}
|
|
275
343
|
|
|
344
|
+
/**
|
|
345
|
+
* `bundle check` — does the manifest agree with the bundle it points at?
|
|
346
|
+
*
|
|
347
|
+
* Three ways a package can be built successfully and still not load, none of
|
|
348
|
+
* which the bundler can see because each is a disagreement between two files:
|
|
349
|
+
* the entry listed under both `esmodules` and `scripts` (Foundry loads it
|
|
350
|
+
* twice), under neither (Foundry never loads it), or under `esmodules` while
|
|
351
|
+
* the emitted file only parses as a classic script. The last fails at runtime
|
|
352
|
+
* with a message about whichever `import` came first and says nothing about the
|
|
353
|
+
* manifest, which is the kind of error that costs an afternoon.
|
|
354
|
+
*
|
|
355
|
+
* Both files are read from the stage, because the stage is what ships. Checking
|
|
356
|
+
* sources would answer for a package nobody installs.
|
|
357
|
+
*
|
|
358
|
+
* @returns {object} The yargs command module.
|
|
359
|
+
*/
|
|
360
|
+
function bundleCommand() {
|
|
361
|
+
return {
|
|
362
|
+
command: "bundle <action>",
|
|
363
|
+
describe: "Code-bundle checks",
|
|
364
|
+
builder: (y) =>
|
|
365
|
+
y.positional("action", {
|
|
366
|
+
choices: ["check"],
|
|
367
|
+
describe:
|
|
368
|
+
"check: verify the manifest and the staged bundle agree",
|
|
369
|
+
}),
|
|
370
|
+
handler: handler(() => {
|
|
371
|
+
const config = loadPackageBuildConfig();
|
|
372
|
+
const stageDir = path.join(config.rootDir, config.stageDir);
|
|
373
|
+
const manifestPath = path.join(stageDir, `${config.artifact}.json`);
|
|
374
|
+
const bundlePath = path.join(stageDir, config.bundleEntry);
|
|
375
|
+
|
|
376
|
+
// Named explicitly rather than left to a missing-file stack trace:
|
|
377
|
+
// both absences mean "the stage was not built", and a reader who
|
|
378
|
+
// sees the path knows which step to run.
|
|
379
|
+
for (const [what, where] of [
|
|
380
|
+
["manifest", manifestPath],
|
|
381
|
+
["bundle", bundlePath],
|
|
382
|
+
]) {
|
|
383
|
+
if (!fs.existsSync(where)) {
|
|
384
|
+
die(
|
|
385
|
+
`no staged ${what} at ` +
|
|
386
|
+
`${path.relative(config.rootDir, where)} — build ` +
|
|
387
|
+
`the stage before checking it.`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const relative = path.relative(config.rootDir, bundlePath);
|
|
393
|
+
const { findings, declaredAs } = checkBundleLoading({
|
|
394
|
+
manifest: JSON.parse(fs.readFileSync(manifestPath, "utf8")),
|
|
395
|
+
source: fs.readFileSync(bundlePath, "utf8"),
|
|
396
|
+
entry: config.bundleEntry,
|
|
397
|
+
manifestName: path.relative(config.rootDir, manifestPath),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (reportFindings(findings, { file: relative })) process.exit(1);
|
|
401
|
+
|
|
402
|
+
console.log(
|
|
403
|
+
`package-build: ${config.bundleEntry} is declared under ` +
|
|
404
|
+
`"${declaredAs}" and loads as one.`,
|
|
405
|
+
);
|
|
406
|
+
}),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
276
410
|
/**
|
|
277
411
|
* `release` — zip the staged package for a GitHub release.
|
|
278
412
|
*
|
|
@@ -352,7 +486,9 @@ yargs(hideBin(process.argv))
|
|
|
352
486
|
.scriptName("package-build")
|
|
353
487
|
.command(cleanCommand())
|
|
354
488
|
.command(assetsCommand())
|
|
489
|
+
.command(manifestCommand())
|
|
355
490
|
.command(langCommand())
|
|
491
|
+
.command(bundleCommand())
|
|
356
492
|
.command(releaseCommand())
|
|
357
493
|
.command(deployCommand())
|
|
358
494
|
.demandCommand(1, "Name a command.")
|
package/bin/report.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
* How the command line reports what a rule found.
|
|
16
|
+
*
|
|
17
|
+
* The package's rules are pure: they take source text and return findings,
|
|
18
|
+
* leaving discovery, I/O and reporting to whoever called them. That is what
|
|
19
|
+
* makes them testable, but it also means every caller has to decide the same
|
|
20
|
+
* two things — what a finding looks like once emitted, and what a run's exit
|
|
21
|
+
* code should be. Before there was a command line, every consumer decided them
|
|
22
|
+
* separately, and no two agreed.
|
|
23
|
+
*
|
|
24
|
+
* This module is where the binary decides them once. It lives under `bin/`
|
|
25
|
+
* rather than beside the rules deliberately: it is the *caller's* half, not
|
|
26
|
+
* part of the pure surface, and nothing importing this package as a library
|
|
27
|
+
* should reach it.
|
|
28
|
+
*
|
|
29
|
+
* The emitted form is the toolchain's diagnostic contract —
|
|
30
|
+
* `file:line:column: severity: message`, the path starting the line, a field
|
|
31
|
+
* dropped rather than guessed — which `@heroiclands/content-build` already
|
|
32
|
+
* owns. This module maps findings onto it rather than restating it, so the two
|
|
33
|
+
* packages cannot drift into two nearly-identical formats.
|
|
34
|
+
*
|
|
35
|
+
* @module
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { emitDiagnostic } from "@heroiclands/content-build/engine/diagnostics";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A finding as the pure rules report one.
|
|
42
|
+
*
|
|
43
|
+
* @typedef {object} Finding
|
|
44
|
+
* @property {string} message What is wrong, in one sentence.
|
|
45
|
+
* @property {"warning"|"error"} [severity] Defaults to `error`.
|
|
46
|
+
* @property {number} [line] 1-based line, when known.
|
|
47
|
+
* @property {number} [column] 1-based column, when known.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Turn a rule's findings into diagnostics, attaching the file they are about.
|
|
52
|
+
*
|
|
53
|
+
* A rule is handed source text and never learns the path it came from, so the
|
|
54
|
+
* caller is the only one who can say it — and a finding without a file is one
|
|
55
|
+
* nothing can navigate to.
|
|
56
|
+
*
|
|
57
|
+
* **A field is dropped, never guessed.** A finding that knows no line emits as
|
|
58
|
+
* `file: severity: message`, because defaulting to `1:1` sends a reader to the
|
|
59
|
+
* top of the file every time and reads exactly like a real position. A column
|
|
60
|
+
* without a line is dropped for the same reason: it locates nothing on its own.
|
|
61
|
+
*
|
|
62
|
+
* @param {Finding[]} findings - What the rule returned.
|
|
63
|
+
* @param {object} opts
|
|
64
|
+
* @param {string} opts.file - Path to the file, relative to the working
|
|
65
|
+
* directory, so the emitted line is one an editor or `cc`-style parser can
|
|
66
|
+
* open.
|
|
67
|
+
* @returns {Array<{file: string, line?: number, column?: number,
|
|
68
|
+
* severity: "warning"|"error", message: string}>} The diagnostics, in the
|
|
69
|
+
* order the rule reported them.
|
|
70
|
+
*/
|
|
71
|
+
export function toDiagnostics(findings, { file }) {
|
|
72
|
+
return findings.map((finding) => {
|
|
73
|
+
const hasLine = finding.line !== undefined && finding.line !== null;
|
|
74
|
+
const hasColumn =
|
|
75
|
+
finding.column !== undefined && finding.column !== null;
|
|
76
|
+
return {
|
|
77
|
+
file,
|
|
78
|
+
...(hasLine ? { line: finding.line } : {}),
|
|
79
|
+
// Only alongside a line: `formatLocator` ignores a lone column, and
|
|
80
|
+
// carrying it anyway would invite a reader to trust it.
|
|
81
|
+
...(hasLine && hasColumn ? { column: finding.column } : {}),
|
|
82
|
+
severity: finding.severity ?? "error",
|
|
83
|
+
message: finding.message,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Emit a rule's findings and say how many of them fail the run.
|
|
90
|
+
*
|
|
91
|
+
* Warnings are emitted and counted separately from the return value, so a
|
|
92
|
+
* command can report something worth reading without failing a build over it.
|
|
93
|
+
*
|
|
94
|
+
* @param {Finding[]} findings - What the rule returned.
|
|
95
|
+
* @param {object} opts
|
|
96
|
+
* @param {string} opts.file - Path to the file, relative to the working dir.
|
|
97
|
+
* @param {(d: object) => void} [opts.emit] - How to emit one diagnostic.
|
|
98
|
+
* Injectable so a test can capture the emitted shape without reaching for
|
|
99
|
+
* the console; defaults to content-build's `emitDiagnostic`, which writes
|
|
100
|
+
* both severities to stderr.
|
|
101
|
+
* @returns {number} How many diagnostics were errors — the count a command
|
|
102
|
+
* turns into its exit code.
|
|
103
|
+
*/
|
|
104
|
+
export function reportFindings(findings, { file, emit = emitDiagnostic }) {
|
|
105
|
+
const diagnostics = toDiagnostics(findings, { file });
|
|
106
|
+
for (const diagnostic of diagnostics) emit(diagnostic);
|
|
107
|
+
return diagnostics.filter((d) => d.severity === "error").length;
|
|
108
|
+
}
|
package/config.mjs
CHANGED
|
@@ -63,16 +63,43 @@ const SECTION_KEYS = [
|
|
|
63
63
|
"stageDir",
|
|
64
64
|
"assets",
|
|
65
65
|
"assetTransform",
|
|
66
|
+
"manifest",
|
|
67
|
+
"manifestFlags",
|
|
66
68
|
"clean",
|
|
67
69
|
"lang",
|
|
68
70
|
"deploy",
|
|
69
71
|
"release",
|
|
72
|
+
"bundle",
|
|
70
73
|
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Manifest keys a repository may **not** declare, because the build derives
|
|
77
|
+
* them and would only overwrite what was written.
|
|
78
|
+
*
|
|
79
|
+
* Silently overwriting is the failure this list exists to prevent: a
|
|
80
|
+
* `version` typed into the configuration would look authoritative, sit there
|
|
81
|
+
* unread, and disagree with the shipped package forever. Declaring one is an
|
|
82
|
+
* error naming the key and where the value actually comes from.
|
|
83
|
+
*
|
|
84
|
+
* @type {Readonly<Record<string, string>>}
|
|
85
|
+
*/
|
|
86
|
+
export const DERIVED_MANIFEST_KEYS = Object.freeze({
|
|
87
|
+
id: "`foundryPackage`, itself derived from package.json `name`",
|
|
88
|
+
version: "package.json `version`",
|
|
89
|
+
url: "package.json `repository`",
|
|
90
|
+
bugs: "package.json `repository`",
|
|
91
|
+
manifest: "package.json `repository` and the release tag",
|
|
92
|
+
download: "package.json `repository` and the release tag",
|
|
93
|
+
compatibility: "the top level of content-build.config.yaml",
|
|
94
|
+
relationships: "the top level of content-build.config.yaml",
|
|
95
|
+
packs: "the `packs` list at the top level of content-build.config.yaml",
|
|
96
|
+
});
|
|
71
97
|
const ASSET_KEYS = ["from", "to"];
|
|
72
98
|
const CLEAN_KEYS = ["extra"];
|
|
73
99
|
const LANG_KEYS = ["sources", "help"];
|
|
74
100
|
const DEPLOY_KEYS = ["envPrefix"];
|
|
75
101
|
const RELEASE_KEYS = ["artifact"];
|
|
102
|
+
const BUNDLE_KEYS = ["entry"];
|
|
76
103
|
|
|
77
104
|
/**
|
|
78
105
|
* The artifact name each package kind ships, so no repository states it.
|
|
@@ -156,6 +183,41 @@ function normalizeAsset(value, index) {
|
|
|
156
183
|
});
|
|
157
184
|
}
|
|
158
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Validate the manifest specification.
|
|
188
|
+
*
|
|
189
|
+
* **Deliberately not key-checked.** Everything a repository declares here is
|
|
190
|
+
* emitted into the manifest unchanged, so a key Foundry adds in a later version
|
|
191
|
+
* can be declared without waiting for a release of this package. The only rule
|
|
192
|
+
* is the one that has a wrong answer rather than an unknown one: a key the
|
|
193
|
+
* build *derives* must not also be authored, because the authored value would
|
|
194
|
+
* be silently overwritten.
|
|
195
|
+
*
|
|
196
|
+
* That is also why it is its own block rather than being spread across
|
|
197
|
+
* `packageBuild:` directly — pass-through and unknown-key checking cannot
|
|
198
|
+
* coexist in one mapping, and the keys around it are worth checking.
|
|
199
|
+
*
|
|
200
|
+
* @param {unknown} value - The `manifest` block, or `undefined`.
|
|
201
|
+
* @returns {Readonly<Record<string, unknown>>} It, frozen; `{}` when absent.
|
|
202
|
+
*/
|
|
203
|
+
function normalizeManifest(value) {
|
|
204
|
+
if (value === undefined) return Object.freeze({});
|
|
205
|
+
if (!isMapping(value)) fail("packageBuild.manifest", "must be a mapping");
|
|
206
|
+
const input = /** @type {Record<string, unknown>} */ (value);
|
|
207
|
+
|
|
208
|
+
for (const [key, source] of Object.entries(DERIVED_MANIFEST_KEYS)) {
|
|
209
|
+
if (input[key] !== undefined) {
|
|
210
|
+
fail(
|
|
211
|
+
`packageBuild.manifest.${key}`,
|
|
212
|
+
`is derived from ${source} and must not be declared — it ` +
|
|
213
|
+
`would be overwritten, and the two would disagree with ` +
|
|
214
|
+
`nothing to say so`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return Object.freeze(structuredClone(input));
|
|
219
|
+
}
|
|
220
|
+
|
|
159
221
|
/**
|
|
160
222
|
* The resolved `packageBuild` section, every optional half filled in.
|
|
161
223
|
*
|
|
@@ -167,6 +229,11 @@ function normalizeAsset(value, index) {
|
|
|
167
229
|
* @property {string} stageDir The staged package root, relative to
|
|
168
230
|
* `rootDir`. Every asset `to:` lands under it.
|
|
169
231
|
* @property {readonly Readonly<AssetSpec>[]} assets
|
|
232
|
+
* @property {Readonly<Record<string, unknown>>} manifest The manifest
|
|
233
|
+
* specification, emitted as declared.
|
|
234
|
+
* @property {string|null} manifestFlags Module to load a `flags` function
|
|
235
|
+
* from, for namespaced flags a repository has
|
|
236
|
+
* to compute. `null` when it declares none.
|
|
170
237
|
* @property {string|null} assetTransform Module to load a `transform` from,
|
|
171
238
|
* resolved against `rootDir`. `null` when
|
|
172
239
|
* the repository stages assets verbatim.
|
|
@@ -175,6 +242,8 @@ function normalizeAsset(value, index) {
|
|
|
175
242
|
* @property {string} langSources Glob for the localization files to check.
|
|
176
243
|
* @property {string|null} langHelp Extra guidance printed after a failure.
|
|
177
244
|
* @property {string} envPrefix Prefix of the deploy environment variables.
|
|
245
|
+
* @property {string} bundleEntry The bundle file Foundry loads, as the
|
|
246
|
+
* manifest spells it. Derived from the package id.
|
|
178
247
|
*/
|
|
179
248
|
|
|
180
249
|
/**
|
|
@@ -243,6 +312,15 @@ export function resolvePackageBuildConfig(shared) {
|
|
|
243
312
|
);
|
|
244
313
|
const releaseInput = /** @type {Record<string, unknown>} */ (release);
|
|
245
314
|
|
|
315
|
+
const bundle = section.bundle ?? {};
|
|
316
|
+
if (!isMapping(bundle)) fail("packageBuild.bundle", "must be a mapping");
|
|
317
|
+
rejectUnknownKeys(
|
|
318
|
+
/** @type {Record<string, unknown>} */ (bundle),
|
|
319
|
+
BUNDLE_KEYS,
|
|
320
|
+
"packageBuild.bundle.",
|
|
321
|
+
);
|
|
322
|
+
const bundleInput = /** @type {Record<string, unknown>} */ (bundle);
|
|
323
|
+
|
|
246
324
|
return Object.freeze({
|
|
247
325
|
rootDir: shared.rootDir,
|
|
248
326
|
// Where the package is assembled before it is zipped or deployed. Every
|
|
@@ -280,6 +358,17 @@ export function resolvePackageBuildConfig(shared) {
|
|
|
280
358
|
"packageBuild.assetTransform",
|
|
281
359
|
),
|
|
282
360
|
),
|
|
361
|
+
manifest: normalizeManifest(section.manifest),
|
|
362
|
+
manifestFlags:
|
|
363
|
+
section.manifestFlags === undefined ?
|
|
364
|
+
null
|
|
365
|
+
: path.resolve(
|
|
366
|
+
shared.rootDir,
|
|
367
|
+
requireNonEmptyString(
|
|
368
|
+
section.manifestFlags,
|
|
369
|
+
"packageBuild.manifestFlags",
|
|
370
|
+
),
|
|
371
|
+
),
|
|
283
372
|
cleanExtra: Object.freeze(cleanExtra),
|
|
284
373
|
langSources:
|
|
285
374
|
langInput.sources === undefined ?
|
|
@@ -299,6 +388,22 @@ export function resolvePackageBuildConfig(shared) {
|
|
|
299
388
|
deployInput.envPrefix,
|
|
300
389
|
"packageBuild.deploy.envPrefix",
|
|
301
390
|
),
|
|
391
|
+
// The file Foundry loads, as the manifest spells it. Named after the
|
|
392
|
+
// package by convention, and the id is already derived from
|
|
393
|
+
// package.json `name` — so a repository states this only when its
|
|
394
|
+
// bundler emits something else.
|
|
395
|
+
//
|
|
396
|
+
// Deliberately *not* read back out of the generated manifest: the
|
|
397
|
+
// check's question is whether the manifest declares this file the way
|
|
398
|
+
// Foundry needs it, and a value taken from the manifest could never
|
|
399
|
+
// answer that — it would agree with itself by construction.
|
|
400
|
+
bundleEntry:
|
|
401
|
+
bundleInput.entry === undefined ?
|
|
402
|
+
`${shared.foundryPackage}.mjs`
|
|
403
|
+
: requireNonEmptyString(
|
|
404
|
+
bundleInput.entry,
|
|
405
|
+
"packageBuild.bundle.entry",
|
|
406
|
+
),
|
|
302
407
|
});
|
|
303
408
|
}
|
|
304
409
|
|
package/manifest.mjs
CHANGED
|
@@ -15,26 +15,35 @@
|
|
|
15
15
|
* Building the Foundry package manifest — `system.json` or `module.json`.
|
|
16
16
|
*
|
|
17
17
|
* Foundry defines exactly two package kinds, and a repository is one of them,
|
|
18
|
-
* so there is one job here with two spellings:
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
18
|
+
* so there is one job here with two spellings: assemble the manifest from the
|
|
19
|
+
* repository's configuration and write it into the build stage.
|
|
20
|
+
*
|
|
21
|
+
* **There is no template any more.** A manifest used to be a hand-authored
|
|
22
|
+
* `system.template.json` that this module stamped a few fields into — which
|
|
23
|
+
* made it the one build input still written as JSON, by hand, per repository,
|
|
24
|
+
* with no schema and nothing checking it. Worse, it declared facts the
|
|
25
|
+
* configuration also declared: the pack list twice, in two formats, with
|
|
26
|
+
* nothing checking that the pairs agreed. `sohl-kethira-basic` hand-maintained
|
|
27
|
+
* its whole `module.json`, and its `download` named an older version than the
|
|
28
|
+
* module claimed.
|
|
29
|
+
*
|
|
30
|
+
* So the manifest is generated (#9). Three kinds of key end up in it:
|
|
31
|
+
*
|
|
32
|
+
* - **Declared** — the `packageBuild.manifest` block, emitted unchanged, so a
|
|
33
|
+
* key Foundry adds in a later version needs no release of this package.
|
|
34
|
+
* - **Derived** — the identity, the version, the release addresses, the
|
|
35
|
+
* compatibility ranges and the pack list. Declaring one of these is an error
|
|
36
|
+
* rather than an override: the authored copy would be silently overwritten.
|
|
37
|
+
* - **Computed** — namespaced `flags` a repository works out for itself.
|
|
29
38
|
*
|
|
30
39
|
* **Nothing here invents an address.** The repository URL is read from
|
|
31
|
-
* `package.json`'s `repository` field, normalised, and everything else
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
40
|
+
* `package.json`'s `repository` field, normalised, and everything else derived
|
|
41
|
+
* from it. A manifest advertising another package's URLs would send Foundry to
|
|
42
|
+
* the wrong release on every update check — exactly what a template copied
|
|
43
|
+
* between repositories produced.
|
|
35
44
|
*
|
|
36
45
|
* The rules are pure functions over data. I/O is confined to
|
|
37
|
-
* {@link
|
|
46
|
+
* {@link writeManifest}, which is the only export that touches disk.
|
|
38
47
|
*
|
|
39
48
|
* @module
|
|
40
49
|
*/
|
|
@@ -52,34 +61,6 @@ import path from "node:path";
|
|
|
52
61
|
*/
|
|
53
62
|
export const ARTIFACTS = Object.freeze(["system", "module"]);
|
|
54
63
|
|
|
55
|
-
/**
|
|
56
|
-
* Which artifact a template file builds.
|
|
57
|
-
*
|
|
58
|
-
* Inferred from the template's own name so the usual case takes no
|
|
59
|
-
* configuration: a repository that ships `system.template.json` is a system,
|
|
60
|
-
* and one that ships `module.template.json` is a module. That is the same pair
|
|
61
|
-
* `@heroiclands/content-build` resolves a package manifest from, so the two
|
|
62
|
-
* cannot disagree about what a repository is.
|
|
63
|
-
*
|
|
64
|
-
* @param {string} templatePath - Path to the manifest template.
|
|
65
|
-
* @returns {"system"|"module"} The artifact name.
|
|
66
|
-
* @throws {TypeError} When the name identifies neither kind — a template called
|
|
67
|
-
* something else leaves nothing to infer from, and guessing would silently
|
|
68
|
-
* emit a manifest Foundry never looks for.
|
|
69
|
-
*/
|
|
70
|
-
export function artifactFromTemplate(templatePath) {
|
|
71
|
-
const base = path.basename(String(templatePath ?? ""));
|
|
72
|
-
const artifact = ARTIFACTS.find((a) => base.startsWith(`${a}.`));
|
|
73
|
-
if (!artifact) {
|
|
74
|
-
throw new TypeError(
|
|
75
|
-
`Cannot tell whether "${base}" builds a system or a module. ` +
|
|
76
|
-
`Name it system.template.json or module.template.json, or pass ` +
|
|
77
|
-
`\`artifact\` explicitly.`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
return artifact;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
64
|
/**
|
|
84
65
|
* The repository's web address, from whatever spelling `package.json` carries.
|
|
85
66
|
*
|
|
@@ -138,80 +119,155 @@ export function releaseUrls({ repoUrl, version, artifact }) {
|
|
|
138
119
|
}
|
|
139
120
|
|
|
140
121
|
/**
|
|
141
|
-
*
|
|
122
|
+
* The order the manifest's keys are written in.
|
|
142
123
|
*
|
|
143
|
-
*
|
|
124
|
+
* Foundry does not care, but a human reading a diff does, and the generated
|
|
125
|
+
* file has to be comparable against the hand-authored template it replaces —
|
|
126
|
+
* which is only possible if the order is fixed rather than incidental to which
|
|
127
|
+
* keys a repository happened to declare. Anything not listed keeps its declared
|
|
128
|
+
* order, after these.
|
|
144
129
|
*
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
130
|
+
* @type {readonly string[]}
|
|
131
|
+
*/
|
|
132
|
+
const MANIFEST_KEY_ORDER = Object.freeze([
|
|
133
|
+
"id",
|
|
134
|
+
"title",
|
|
135
|
+
"description",
|
|
136
|
+
"version",
|
|
137
|
+
"authors",
|
|
138
|
+
"license",
|
|
139
|
+
"readme",
|
|
140
|
+
"changelog",
|
|
141
|
+
"flags",
|
|
142
|
+
"compatibility",
|
|
143
|
+
"relationships",
|
|
144
|
+
"esmodules",
|
|
145
|
+
"styles",
|
|
146
|
+
"languages",
|
|
147
|
+
"documentTypes",
|
|
148
|
+
"packFolders",
|
|
149
|
+
"packs",
|
|
150
|
+
"media",
|
|
151
|
+
"socket",
|
|
152
|
+
"grid",
|
|
153
|
+
"primaryTokenAttribute",
|
|
154
|
+
"url",
|
|
155
|
+
"bugs",
|
|
156
|
+
"manifest",
|
|
157
|
+
"download",
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The manifest's `packs`, derived from the one pack list the build already has.
|
|
150
162
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
163
|
+
* The two used to be written separately — `content-build.config.yaml` declared
|
|
164
|
+
* a pack's name and type, and the manifest template declared them again beside
|
|
165
|
+
* a label, a path and a system id, with nothing checking that the pairs agreed.
|
|
166
|
+
* They are one list now.
|
|
167
|
+
*
|
|
168
|
+
* Companions are flattened in, because Foundry sees no difference: a companion
|
|
169
|
+
* is only a pack written by another pass rather than one of its own, and it
|
|
170
|
+
* ships as an ordinary compendium. The order matches `packDirectories`, so the
|
|
171
|
+
* manifest lists packs in the order the build compiles them.
|
|
172
|
+
*
|
|
173
|
+
* @param {object} config - The resolved content-build configuration.
|
|
174
|
+
* @returns {object[]} The manifest's `packs` array.
|
|
158
175
|
*/
|
|
159
|
-
export function
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
export function manifestPacks(config) {
|
|
177
|
+
const flatten = (pack) => [
|
|
178
|
+
pack,
|
|
179
|
+
...(pack.companions ?? []).flatMap(flatten),
|
|
180
|
+
];
|
|
181
|
+
return config.packs.flatMap(flatten).map((pack) => ({
|
|
182
|
+
label: pack.label,
|
|
183
|
+
type: pack.type,
|
|
184
|
+
name: pack.name,
|
|
185
|
+
system: config.stats.systemId,
|
|
186
|
+
path: `packs/${pack.name}`,
|
|
187
|
+
private: pack.private,
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build a Foundry package manifest from the resolved configuration.
|
|
193
|
+
*
|
|
194
|
+
* Three kinds of key end up in the result:
|
|
195
|
+
*
|
|
196
|
+
* - **Declared** — everything in `packageBuild.manifest`, emitted unchanged, so
|
|
197
|
+
* a key Foundry adds later needs no release of this package.
|
|
198
|
+
* - **Derived** — the identity, the release addresses, the version, the Foundry
|
|
199
|
+
* and system compatibility ranges, and the pack list. These are refused if
|
|
200
|
+
* also declared: an authored copy would be overwritten and the two would
|
|
201
|
+
* disagree with nothing to say so.
|
|
202
|
+
* - **Computed** — namespaced `flags` a repository works out for itself, merged
|
|
203
|
+
* over any it declared.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} options - Inputs.
|
|
206
|
+
* @param {object} options.config - The resolved content-build configuration.
|
|
207
|
+
* @param {object} options.packageJson - The repository's `package.json`.
|
|
208
|
+
* @param {string} options.artifact - `system` or `module`.
|
|
209
|
+
* @param {Record<string, object>} [options.flags] - Namespaced flags to merge.
|
|
210
|
+
* @returns {object} The manifest, ready to serialise.
|
|
211
|
+
*/
|
|
212
|
+
export function buildManifest({ config, packageJson, artifact, flags }) {
|
|
213
|
+
const declared = config.packageBuild?.manifest ?? {};
|
|
214
|
+
const repoUrl = normalizeRepoUrl(packageJson.repository);
|
|
215
|
+
|
|
216
|
+
const derived = {
|
|
217
|
+
id: config.foundryPackage,
|
|
218
|
+
version: packageJson.version,
|
|
219
|
+
packs: manifestPacks(config),
|
|
220
|
+
...releaseUrls({ repoUrl, version: packageJson.version, artifact }),
|
|
164
221
|
};
|
|
222
|
+
if (config.compatibility) derived.compatibility = config.compatibility;
|
|
223
|
+
if (config.relationships && Object.keys(config.relationships).length) {
|
|
224
|
+
derived.relationships = config.relationships;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const merged = { ...declared, ...derived };
|
|
165
228
|
|
|
166
229
|
if (flags && Object.keys(flags).length) {
|
|
167
|
-
|
|
230
|
+
merged.flags = { ...(declared.flags ?? {}) };
|
|
168
231
|
for (const [namespace, values] of Object.entries(flags)) {
|
|
169
|
-
|
|
170
|
-
...(
|
|
232
|
+
merged.flags[namespace] = {
|
|
233
|
+
...(declared.flags?.[namespace] ?? {}),
|
|
171
234
|
...values,
|
|
172
235
|
};
|
|
173
236
|
}
|
|
174
237
|
}
|
|
175
238
|
|
|
176
|
-
|
|
239
|
+
// Ordered, so the generated file diffs against the template it replaces.
|
|
240
|
+
const ordered = {};
|
|
241
|
+
for (const key of MANIFEST_KEY_ORDER) {
|
|
242
|
+
if (merged[key] !== undefined) ordered[key] = merged[key];
|
|
243
|
+
}
|
|
244
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
245
|
+
if (!(key in ordered)) ordered[key] = value;
|
|
246
|
+
}
|
|
247
|
+
return ordered;
|
|
177
248
|
}
|
|
178
249
|
|
|
179
250
|
/**
|
|
180
|
-
*
|
|
251
|
+
* Write the generated manifest into the staged package.
|
|
181
252
|
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* @param {
|
|
186
|
-
* @param {string}
|
|
187
|
-
* @param {object}
|
|
188
|
-
*
|
|
189
|
-
* @param {string} opts.outDir - Directory to write the manifest into, created
|
|
190
|
-
* if absent.
|
|
191
|
-
* @param {"system"|"module"} [opts.artifact] - Overrides the artifact inferred
|
|
192
|
-
* from the template's name.
|
|
193
|
-
* @param {Record<string, object>} [opts.flags] - Namespaced flags to merge.
|
|
194
|
-
* @returns {Promise<{path: string, manifest: object}>} Where it was written,
|
|
195
|
-
* and what was written.
|
|
253
|
+
* @param {object} options - As {@link buildManifest}, plus where to write.
|
|
254
|
+
* @param {object} options.config - The resolved content-build configuration.
|
|
255
|
+
* @param {object} options.packageJson - The repository's `package.json`.
|
|
256
|
+
* @param {string} options.artifact - `system` or `module`.
|
|
257
|
+
* @param {string} options.outDir - Directory to write into.
|
|
258
|
+
* @param {Record<string, object>} [options.flags] - Namespaced flags to merge.
|
|
259
|
+
* @returns {Promise<{path: string, manifest: object}>} Where it went, and what.
|
|
196
260
|
*/
|
|
197
|
-
export async function
|
|
198
|
-
|
|
261
|
+
export async function writeManifest({
|
|
262
|
+
config,
|
|
199
263
|
packageJson,
|
|
264
|
+
artifact,
|
|
200
265
|
outDir,
|
|
201
|
-
|
|
202
|
-
flags = undefined,
|
|
266
|
+
flags,
|
|
203
267
|
}) {
|
|
204
|
-
const
|
|
205
|
-
const template = JSON.parse(await fs.readFile(templatePath, "utf8"));
|
|
206
|
-
const manifest = stampManifest(template, {
|
|
207
|
-
version: packageJson.version,
|
|
208
|
-
repoUrl: normalizeRepoUrl(packageJson.repository),
|
|
209
|
-
artifact: kind,
|
|
210
|
-
flags,
|
|
211
|
-
});
|
|
212
|
-
|
|
268
|
+
const manifest = buildManifest({ config, packageJson, artifact, flags });
|
|
213
269
|
await fs.mkdir(outDir, { recursive: true });
|
|
214
|
-
const outPath = path.join(outDir, `${
|
|
270
|
+
const outPath = path.join(outDir, `${artifact}.json`);
|
|
215
271
|
// Trailing newline: the file is committed to a release archive and read by
|
|
216
272
|
// humans as often as by Foundry.
|
|
217
273
|
await fs.writeFile(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"prepare": "git config core.hooksPath .githooks || true"
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
|
-
"@heroiclands/content-build": "^0.
|
|
75
|
+
"@heroiclands/content-build": "^1.0.0",
|
|
76
76
|
"acorn": "^8.18.0",
|
|
77
77
|
"archiver": "^8.0.0",
|
|
78
78
|
"dotenv": "^17.2.3",
|
package/types/config.d.mts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* @property {string} stageDir The staged package root, relative to
|
|
10
10
|
* `rootDir`. Every asset `to:` lands under it.
|
|
11
11
|
* @property {readonly Readonly<AssetSpec>[]} assets
|
|
12
|
+
* @property {Readonly<Record<string, unknown>>} manifest The manifest
|
|
13
|
+
* specification, emitted as declared.
|
|
14
|
+
* @property {string|null} manifestFlags Module to load a `flags` function
|
|
15
|
+
* from, for namespaced flags a repository has
|
|
16
|
+
* to compute. `null` when it declares none.
|
|
12
17
|
* @property {string|null} assetTransform Module to load a `transform` from,
|
|
13
18
|
* resolved against `rootDir`. `null` when
|
|
14
19
|
* the repository stages assets verbatim.
|
|
@@ -17,6 +22,8 @@
|
|
|
17
22
|
* @property {string} langSources Glob for the localization files to check.
|
|
18
23
|
* @property {string|null} langHelp Extra guidance printed after a failure.
|
|
19
24
|
* @property {string} envPrefix Prefix of the deploy environment variables.
|
|
25
|
+
* @property {string} bundleEntry The bundle file Foundry loads, as the
|
|
26
|
+
* manifest spells it. Derived from the package id.
|
|
20
27
|
*/
|
|
21
28
|
/**
|
|
22
29
|
* Resolve a package-build configuration from an already-loaded shared one.
|
|
@@ -44,6 +51,18 @@ export function resolvePackageBuildConfig(shared: object): Readonly<PackageBuild
|
|
|
44
51
|
* declares something malformed.
|
|
45
52
|
*/
|
|
46
53
|
export function loadPackageBuildConfig(): Readonly<PackageBuildConfig>;
|
|
54
|
+
/**
|
|
55
|
+
* Manifest keys a repository may **not** declare, because the build derives
|
|
56
|
+
* them and would only overwrite what was written.
|
|
57
|
+
*
|
|
58
|
+
* Silently overwriting is the failure this list exists to prevent: a
|
|
59
|
+
* `version` typed into the configuration would look authoritative, sit there
|
|
60
|
+
* unread, and disagree with the shipped package forever. Declaring one is an
|
|
61
|
+
* error naming the key and where the value actually comes from.
|
|
62
|
+
*
|
|
63
|
+
* @type {Readonly<Record<string, string>>}
|
|
64
|
+
*/
|
|
65
|
+
export const DERIVED_MANIFEST_KEYS: Readonly<Record<string, string>>;
|
|
47
66
|
/**
|
|
48
67
|
* One staging copy: a source path in the repository, and where it lands under
|
|
49
68
|
* the staged package root.
|
|
@@ -84,6 +103,17 @@ export type PackageBuildConfig = {
|
|
|
84
103
|
*/
|
|
85
104
|
stageDir: string;
|
|
86
105
|
assets: readonly Readonly<AssetSpec>[];
|
|
106
|
+
/**
|
|
107
|
+
* The manifest
|
|
108
|
+
* specification, emitted as declared.
|
|
109
|
+
*/
|
|
110
|
+
manifest: Readonly<Record<string, unknown>>;
|
|
111
|
+
/**
|
|
112
|
+
* Module to load a `flags` function
|
|
113
|
+
* from, for namespaced flags a repository has
|
|
114
|
+
* to compute. `null` when it declares none.
|
|
115
|
+
*/
|
|
116
|
+
manifestFlags: string | null;
|
|
87
117
|
/**
|
|
88
118
|
* Module to load a `transform` from,
|
|
89
119
|
* resolved against `rootDir`. `null` when
|
|
@@ -107,4 +137,9 @@ export type PackageBuildConfig = {
|
|
|
107
137
|
* Prefix of the deploy environment variables.
|
|
108
138
|
*/
|
|
109
139
|
envPrefix: string;
|
|
140
|
+
/**
|
|
141
|
+
* The bundle file Foundry loads, as the
|
|
142
|
+
* manifest spells it. Derived from the package id.
|
|
143
|
+
*/
|
|
144
|
+
bundleEntry: string;
|
|
110
145
|
};
|
package/types/manifest.d.mts
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which artifact a template file builds.
|
|
3
|
-
*
|
|
4
|
-
* Inferred from the template's own name so the usual case takes no
|
|
5
|
-
* configuration: a repository that ships `system.template.json` is a system,
|
|
6
|
-
* and one that ships `module.template.json` is a module. That is the same pair
|
|
7
|
-
* `@heroiclands/content-build` resolves a package manifest from, so the two
|
|
8
|
-
* cannot disagree about what a repository is.
|
|
9
|
-
*
|
|
10
|
-
* @param {string} templatePath - Path to the manifest template.
|
|
11
|
-
* @returns {"system"|"module"} The artifact name.
|
|
12
|
-
* @throws {TypeError} When the name identifies neither kind — a template called
|
|
13
|
-
* something else leaves nothing to infer from, and guessing would silently
|
|
14
|
-
* emit a manifest Foundry never looks for.
|
|
15
|
-
*/
|
|
16
|
-
export function artifactFromTemplate(templatePath: string): "system" | "module";
|
|
17
1
|
/**
|
|
18
2
|
* The repository's web address, from whatever spelling `package.json` carries.
|
|
19
3
|
*
|
|
@@ -59,53 +43,65 @@ export function releaseUrls({ repoUrl, version, artifact }: {
|
|
|
59
43
|
download: string;
|
|
60
44
|
};
|
|
61
45
|
/**
|
|
62
|
-
*
|
|
46
|
+
* The manifest's `packs`, derived from the one pack list the build already has.
|
|
63
47
|
*
|
|
64
|
-
*
|
|
48
|
+
* The two used to be written separately — `content-build.config.yaml` declared
|
|
49
|
+
* a pack's name and type, and the manifest template declared them again beside
|
|
50
|
+
* a label, a path and a system id, with nothing checking that the pairs agreed.
|
|
51
|
+
* They are one list now.
|
|
65
52
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* a Foundry package.
|
|
53
|
+
* Companions are flattened in, because Foundry sees no difference: a companion
|
|
54
|
+
* is only a pack written by another pass rather than one of its own, and it
|
|
55
|
+
* ships as an ordinary compendium. The order matches `packDirectories`, so the
|
|
56
|
+
* manifest lists packs in the order the build compiles them.
|
|
71
57
|
*
|
|
72
|
-
* @param {object}
|
|
73
|
-
* @
|
|
74
|
-
* @param {string} opts.version - The version being built.
|
|
75
|
-
* @param {string} opts.repoUrl - Normalised repository URL.
|
|
76
|
-
* @param {"system"|"module"} opts.artifact - Which artifact is shipped.
|
|
77
|
-
* @param {Record<string, object>} [opts.flags] - Namespaced flags to merge.
|
|
78
|
-
* @returns {object} The stamped manifest.
|
|
58
|
+
* @param {object} config - The resolved content-build configuration.
|
|
59
|
+
* @returns {object[]} The manifest's `packs` array.
|
|
79
60
|
*/
|
|
80
|
-
export function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
61
|
+
export function manifestPacks(config: object): object[];
|
|
62
|
+
/**
|
|
63
|
+
* Build a Foundry package manifest from the resolved configuration.
|
|
64
|
+
*
|
|
65
|
+
* Three kinds of key end up in the result:
|
|
66
|
+
*
|
|
67
|
+
* - **Declared** — everything in `packageBuild.manifest`, emitted unchanged, so
|
|
68
|
+
* a key Foundry adds later needs no release of this package.
|
|
69
|
+
* - **Derived** — the identity, the release addresses, the version, the Foundry
|
|
70
|
+
* and system compatibility ranges, and the pack list. These are refused if
|
|
71
|
+
* also declared: an authored copy would be overwritten and the two would
|
|
72
|
+
* disagree with nothing to say so.
|
|
73
|
+
* - **Computed** — namespaced `flags` a repository works out for itself, merged
|
|
74
|
+
* over any it declared.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} options - Inputs.
|
|
77
|
+
* @param {object} options.config - The resolved content-build configuration.
|
|
78
|
+
* @param {object} options.packageJson - The repository's `package.json`.
|
|
79
|
+
* @param {string} options.artifact - `system` or `module`.
|
|
80
|
+
* @param {Record<string, object>} [options.flags] - Namespaced flags to merge.
|
|
81
|
+
* @returns {object} The manifest, ready to serialise.
|
|
82
|
+
*/
|
|
83
|
+
export function buildManifest({ config, packageJson, artifact, flags }: {
|
|
84
|
+
config: object;
|
|
85
|
+
packageJson: object;
|
|
86
|
+
artifact: string;
|
|
84
87
|
flags?: Record<string, object> | undefined;
|
|
85
88
|
}): object;
|
|
86
89
|
/**
|
|
87
|
-
*
|
|
90
|
+
* Write the generated manifest into the staged package.
|
|
88
91
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* @param {
|
|
93
|
-
* @param {string}
|
|
94
|
-
* @param {object}
|
|
95
|
-
*
|
|
96
|
-
* @param {string} opts.outDir - Directory to write the manifest into, created
|
|
97
|
-
* if absent.
|
|
98
|
-
* @param {"system"|"module"} [opts.artifact] - Overrides the artifact inferred
|
|
99
|
-
* from the template's name.
|
|
100
|
-
* @param {Record<string, object>} [opts.flags] - Namespaced flags to merge.
|
|
101
|
-
* @returns {Promise<{path: string, manifest: object}>} Where it was written,
|
|
102
|
-
* and what was written.
|
|
92
|
+
* @param {object} options - As {@link buildManifest}, plus where to write.
|
|
93
|
+
* @param {object} options.config - The resolved content-build configuration.
|
|
94
|
+
* @param {object} options.packageJson - The repository's `package.json`.
|
|
95
|
+
* @param {string} options.artifact - `system` or `module`.
|
|
96
|
+
* @param {string} options.outDir - Directory to write into.
|
|
97
|
+
* @param {Record<string, object>} [options.flags] - Namespaced flags to merge.
|
|
98
|
+
* @returns {Promise<{path: string, manifest: object}>} Where it went, and what.
|
|
103
99
|
*/
|
|
104
|
-
export function
|
|
105
|
-
|
|
100
|
+
export function writeManifest({ config, packageJson, artifact, outDir, flags, }: {
|
|
101
|
+
config: object;
|
|
106
102
|
packageJson: object;
|
|
103
|
+
artifact: string;
|
|
107
104
|
outDir: string;
|
|
108
|
-
artifact?: "module" | "system" | undefined;
|
|
109
105
|
flags?: Record<string, object> | undefined;
|
|
110
106
|
}): Promise<{
|
|
111
107
|
path: string;
|