@heroiclands/package-build 0.1.0 → 0.2.1
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 +140 -0
- package/README.md +135 -0
- package/bin/package-build.mjs +363 -0
- package/config.mjs +318 -0
- package/package.json +22 -5
- package/types/config.d.mts +110 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# @heroiclands/package-build
|
|
2
|
+
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4144291: **Release from merged changesets instead of a remembered command**
|
|
8
|
+
|
|
9
|
+
Fixes [#4](https://github.com/HeroicLands/package-build/issues/4). Releasing was
|
|
10
|
+
hand-driven — bump `package.json` on a branch, merge, then remember
|
|
11
|
+
`gh release create`, because cutting the Release is what published. Nothing
|
|
12
|
+
enforced the last step, so a merged version could sit unpublished with no check
|
|
13
|
+
red; the sibling repository lost two versions that way.
|
|
14
|
+
|
|
15
|
+
- Every pull request now declares its bump as a `.changeset/*.md` file, and CI's
|
|
16
|
+
**Changeset declared** job fails one that does not. `npx changeset add --empty`
|
|
17
|
+
is how a change says it needs no release — explicitly, rather than by omission.
|
|
18
|
+
- Merging to `main` opens a **Version Packages** pull request carrying the bump
|
|
19
|
+
and the rewritten `CHANGELOG.md`. An unreleased state is now a pull request
|
|
20
|
+
waiting in the queue rather than nothing at all.
|
|
21
|
+
- Merging that runs `changeset publish`: npm publish, the `v<version>` tag, and
|
|
22
|
+
the GitHub Release with the changelog section as its body. The OIDC Trusted
|
|
23
|
+
Publishing step is unchanged and still last; there is still no `NPM_TOKEN`, and
|
|
24
|
+
re-running on a published version is a no-op.
|
|
25
|
+
- `CHANGELOG.md` is seeded from the two hand-cut Releases so far and now ships
|
|
26
|
+
with the package. A changeset is also where a raised dependency floor gets
|
|
27
|
+
recorded — 0.2.0 raised one to `@heroiclands/content-build >= 0.15.0` and said
|
|
28
|
+
so nowhere.
|
|
29
|
+
|
|
30
|
+
<!-- Sections at 0.2.1 and above are generated by `changeset version` from the
|
|
31
|
+
changesets merged into `main`. Sections at 0.2.0 and below predate that
|
|
32
|
+
pipeline and are the hand-written GitHub Release notes, kept verbatim
|
|
33
|
+
(headings demoted one level to sit under their version) so no history was
|
|
34
|
+
lost in adopting it. -->
|
|
35
|
+
|
|
36
|
+
## 0.2.0
|
|
37
|
+
|
|
38
|
+
_2026-08-22 — a command line, not a wrapper script per job_
|
|
39
|
+
|
|
40
|
+
**A command line, so a consumer writes configuration instead of scripts.**
|
|
41
|
+
|
|
42
|
+
This package was library-only, so every consuming repository wrote a wrapper script per job — six of them in the Song of Heroic Lands repository, 441 lines that between them contained no logic:
|
|
43
|
+
|
|
44
|
+
| Wrapper | Lines | What was in it |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `build-system-json.mjs` | 143 | |
|
|
47
|
+
| `push-stage.mjs` | 76 | argv, dotenv, then `packageKind: "systems"` and `packageId: "sohl"` hard-coded beside a configuration that already declared both |
|
|
48
|
+
| `copy-assets.mjs` | 71 | a nine-entry data table, plus one genuine repository-specific transform |
|
|
49
|
+
| `check-lang.mjs` | 69 | a glob, a call, and a help string |
|
|
50
|
+
| `clean.mjs` | 47 | a `repoRoot` from `import.meta.url`, one flag, one call — no consumer-specific value at all |
|
|
51
|
+
| `pack-release.mjs` | 35 | one call passing `{ artifact: "system" }`, which `packageKind` already decides |
|
|
52
|
+
|
|
53
|
+
Every copy had drifted from its sibling in the other repositories, because copies do: `clean.mjs` was 47 lines in SoHL and 48 in `sohl-thalorna`, `copy-assets.mjs` 71 and 76. `sohl-thalorna`'s copy still reimplements the recursive directory copy this package has exported since it was extracted, because it was written before the extraction and nobody went back.
|
|
54
|
+
|
|
55
|
+
It is the same shape the configuration had before it became data: not logic, but the boilerplate a code file needs in order to *state a literal*. So the literals moved into configuration, and the boilerplate lives here, once.
|
|
56
|
+
|
|
57
|
+
#### The commands
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
npx package-build clean [--distclean]
|
|
61
|
+
npx package-build assets
|
|
62
|
+
npx package-build lang check
|
|
63
|
+
npx package-build release
|
|
64
|
+
npx package-build deploy <stage>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Wrapped as npm scripts:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"clean": "package-build clean",
|
|
72
|
+
"build:assets": "package-build assets",
|
|
73
|
+
"lint:lang": "package-build lang check",
|
|
74
|
+
"build:pack-release": "package-build release",
|
|
75
|
+
"push:qa": "package-build deploy qa"
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
#### One configuration file, not two
|
|
80
|
+
|
|
81
|
+
Settings come from the reserved `packageBuild:` section of `content-build.config.yaml` — the file a repository already has — which requires **`@heroiclands/content-build` 0.15.0 or later**.
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
packageKind: systems # read from the top level, never restated below
|
|
85
|
+
foundryPackage: sohl
|
|
86
|
+
|
|
87
|
+
packageBuild:
|
|
88
|
+
assets:
|
|
89
|
+
- { from: lang, to: lang }
|
|
90
|
+
assetTransform: ./utils/svg-theme.mjs
|
|
91
|
+
clean:
|
|
92
|
+
extra: [site/content, site/public]
|
|
93
|
+
deploy:
|
|
94
|
+
envPrefix: SOHL
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A second config file would have restated `packageKind` and `foundryPackage`, which is two places for one fact. content-build validates only that the section is a mapping and hands it back frozen; everything inside it is validated here, so neither package learns the other's schema.
|
|
98
|
+
|
|
99
|
+
**Derived, never stated:** the repository root (the config file's own location), `packageKind` and `packageId` (the shared configuration's top level), and the release artifact — a system ships `system.json`, a module `module.json`, so the kind already decides it.
|
|
100
|
+
|
|
101
|
+
**The one genuine piece of consumer code stays the consumer's.** SoHL rewrites each SVG's hard-coded fill so icons follow the Foundry theme; `packageBuild.assetTransform` names a module exporting `transform(sourcePath) -> string | null`.
|
|
102
|
+
|
|
103
|
+
#### Also in this release
|
|
104
|
+
|
|
105
|
+
- `exports["./config"]` now ships its type declaration. It was declared but never generated — `tsconfig.dts.json` lists its inputs explicitly and `config.mjs` was missing from the list — so the subpath would have pointed at a file that does not exist.
|
|
106
|
+
- The library modules are unchanged. Every signature the CLI needed was already there.
|
|
107
|
+
|
|
108
|
+
#### Notes
|
|
109
|
+
|
|
110
|
+
`--version` and `--help` answer in a directory with no configuration at all. Failures report one line rather than a stack, whether the handler was synchronous or asynchronous. Localization findings follow the diagnostics contract, `file:line:column: severity: message`.
|
|
111
|
+
|
|
112
|
+
**Full changelog:** https://github.com/HeroicLands/package-build/compare/v0.1.0...v0.2.0
|
|
113
|
+
|
|
114
|
+
## 0.1.0
|
|
115
|
+
|
|
116
|
+
_2026-08-22 — the Foundry package toolchain_
|
|
117
|
+
|
|
118
|
+
The counterpart to [`@heroiclands/content-build`](https://github.com/HeroicLands/content-build). The two split by **input**, not by repository: content-build reads `assets/content/**` and answers for what a package *says*; this one reads `lang/`, `styles/`, `src/`, `assets/` and the manifest template, and answers for what a package *is* — the parts Foundry loads whether or not the package ships any content.
|
|
119
|
+
|
|
120
|
+
A module uses either, or both. An adventure module that ships only notes needs no bundler; a variant module that ships only behavior needs no Markdown pipeline. The coupling runs one way: package-build asks content-build for the compiled `packs[]` block, never the reverse.
|
|
121
|
+
|
|
122
|
+
### Seven modules — the whole of assemble → validate → ship
|
|
123
|
+
|
|
124
|
+
- **`manifest`** — `system.json` / `module.json`. The artifact is inferred from the template's name, and every address is derived from `package.json`'s `repository` rather than transcribed. Handles the `git+https://….git` spelling npm writes, which yields a 404 on every Foundry update check if left in place.
|
|
125
|
+
- **`stage`** — assembling the build stage and clearing it away. A listed asset that does not exist **fails the build** instead of shipping a package that quietly lacks its localization or templates; the whole list is checked before anything is copied.
|
|
126
|
+
- **`lang`** — what a shippable localization file must satisfy: it parses, its top level is an object, no key is both a leaf and a dotted prefix of another (which makes Foundry discard the entire file), placeholders are single-braced, key segments carry no data.
|
|
127
|
+
- **`bundle`** — whether the manifest agrees with the file it points at. Declared under `"esmodules"` the bundle must parse as a module; declared under `"scripts"` it must declare **nothing** at top level, because each top-level declaration in a classic script is a global lexical binding and one colliding with a non-configurable `window` property throws at parse time. That rule bricked SoHL v0.8.0.
|
|
128
|
+
- **`release`** — the two assets a GitHub Release carries. Waits for the archive to be *written*, not merely finalized.
|
|
129
|
+
- **`deploy`** — installing into a Foundry data directory, local or SFTP, always as a staged atomic swap: a running Foundry holds its LevelDB packs open, and replacing them in place leaves a directory LevelDB "repairs" to zero.
|
|
130
|
+
- **`text`** — locating a literal, so a finding names its line and column.
|
|
131
|
+
|
|
132
|
+
### Design
|
|
133
|
+
|
|
134
|
+
The rules are pure functions over data; the functions that touch disk or a network are named for what they do. That is what makes them testable at all — the scripts they were extracted from each ran their work at import time and exported nothing.
|
|
135
|
+
|
|
136
|
+
### Provenance
|
|
137
|
+
|
|
138
|
+
Developed as a workspace inside the Song of Heroic Lands repository across six changes (SoHL#1680, #1682, #1684, #1685, #1686), exactly as content-build incubated before its own extraction, and extracted now that its shape has stopped changing.
|
|
139
|
+
|
|
140
|
+
128 tests; declaration files are emitted from the JSDoc at pack time.
|
package/README.md
CHANGED
|
@@ -56,6 +56,104 @@ The whole of assemble → validate → ship, one subpath each:
|
|
|
56
56
|
- **`text`** — locating a literal inside a file, so a finding names the line and
|
|
57
57
|
column it is about.
|
|
58
58
|
|
|
59
|
+
## Configure
|
|
60
|
+
|
|
61
|
+
A repository declares its build in **one** file — `content-build.config.yaml`,
|
|
62
|
+
the same one `content-build` reads — and this package takes its settings from
|
|
63
|
+
the reserved `packageBuild:` section:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
# Read from the top level, not restated below.
|
|
67
|
+
packageKind: modules
|
|
68
|
+
foundryPackage: sohl-thalorna
|
|
69
|
+
|
|
70
|
+
packageBuild:
|
|
71
|
+
# Where the package is assembled. Every `to:` below is relative to it, so a
|
|
72
|
+
# table reads `lang`, not `build/stage/lang`.
|
|
73
|
+
stageDir: build/stage
|
|
74
|
+
|
|
75
|
+
assets:
|
|
76
|
+
- { from: lang, to: lang }
|
|
77
|
+
- { from: assets/icons, to: assets/icons }
|
|
78
|
+
- { from: LICENSE.md, to: LICENSE.md }
|
|
79
|
+
|
|
80
|
+
# Optional. A module exporting `transform(sourcePath) -> string | null`,
|
|
81
|
+
# applied to every staged file — `null` copies it verbatim. This is the one
|
|
82
|
+
# genuine piece of code in staging, and it stays the repository's: SoHL
|
|
83
|
+
# rewrites each SVG's hard-coded fill so icons follow the Foundry theme.
|
|
84
|
+
assetTransform: ./utils/svg-theme.mjs
|
|
85
|
+
|
|
86
|
+
clean:
|
|
87
|
+
# Beyond the conventional build artifacts, which the library already knows.
|
|
88
|
+
extra: [site/content, site/public, site/resources]
|
|
89
|
+
|
|
90
|
+
lang:
|
|
91
|
+
sources: lang/*.json
|
|
92
|
+
# Printed after a failure — where this repository documents its key rules.
|
|
93
|
+
help: See kb/dev-docs/reference/localization-keys.md.
|
|
94
|
+
|
|
95
|
+
deploy:
|
|
96
|
+
# Prefix of the shared SFTP override variables. Default `SOHL`.
|
|
97
|
+
envPrefix: SOHL
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Why one file and not two.** Two of the values this package needs —
|
|
101
|
+
`packageKind` and `foundryPackage` — are already declared for `content-build`. A
|
|
102
|
+
second config file would restate them, which is two places for one fact; that is
|
|
103
|
+
exactly what every consumer's `push-stage.mjs` did, hard-coding
|
|
104
|
+
`packageKind: "systems"` and `packageId: "sohl"` beside a configuration that
|
|
105
|
+
already said both.
|
|
106
|
+
|
|
107
|
+
`content-build` checks only that `packageBuild:` is a mapping and hands it back
|
|
108
|
+
frozen. Everything inside it is validated here, so neither package learns the
|
|
109
|
+
other's schema — they split by input, and the dependency runs one way.
|
|
110
|
+
|
|
111
|
+
**What is derived, not stated:**
|
|
112
|
+
|
|
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` |
|
|
118
|
+
|
|
119
|
+
## Command line
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
npx package-build clean [--distclean]
|
|
123
|
+
npx package-build assets
|
|
124
|
+
npx package-build lang check
|
|
125
|
+
npx package-build release
|
|
126
|
+
npx package-build deploy <stage>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Wrapped as npm scripts — SoHL spells them:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"clean": "package-build clean",
|
|
134
|
+
"distclean": "package-build clean --distclean",
|
|
135
|
+
"build:assets": "package-build assets",
|
|
136
|
+
"lint:lang": "package-build lang check",
|
|
137
|
+
"build:pack-release": "package-build release",
|
|
138
|
+
"push:qa": "package-build deploy qa"
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Why the CLI exists.** This package was library-only, so every consuming
|
|
143
|
+
repository wrote a wrapper script per job — six of them in the SoHL repository,
|
|
144
|
+
441 lines that between them contained no logic. `clean.mjs` was 47 lines that
|
|
145
|
+
computed a `repoRoot` from `import.meta.url`, read one flag, and made one call.
|
|
146
|
+
Every copy had drifted from its sibling in the other repositories, because
|
|
147
|
+
copies do: `clean.mjs` was 47 lines in one and 48 in another, `copy-assets.mjs`
|
|
148
|
+
71 and 76.
|
|
149
|
+
|
|
150
|
+
It is the same shape the configuration had before it became data — not logic,
|
|
151
|
+
but the boilerplate a code file needs in order to state a literal. The literals
|
|
152
|
+
moved into configuration; the boilerplate lives in the CLI, once.
|
|
153
|
+
|
|
154
|
+
`--version` and `--help` answer in a directory with no configuration at all.
|
|
155
|
+
Running an actual command resolves it, and fails loudly when it is missing.
|
|
156
|
+
|
|
59
157
|
## Design
|
|
60
158
|
|
|
61
159
|
**The rules are pure, and I/O is confined to functions named for it.** A rule
|
|
@@ -100,6 +198,43 @@ would let something reach for one.
|
|
|
100
198
|
declared `dependency`. Inside a workspace a missing declaration is invisible;
|
|
101
199
|
installed from npm it fails on the first import.
|
|
102
200
|
|
|
201
|
+
## Releasing
|
|
202
|
+
|
|
203
|
+
Releasing is not a command anyone runs. It is a consequence of merging, in two
|
|
204
|
+
steps, and each step is visible while it is pending.
|
|
205
|
+
|
|
206
|
+
**Every pull request declares its bump.** Run `npx changeset` and pick
|
|
207
|
+
major/minor/patch; the summary you write becomes the changelog entry and the
|
|
208
|
+
release note. If the change ships nothing a consumer can see, say so explicitly
|
|
209
|
+
with `npx changeset add --empty`. CI's **Changeset declared** job fails a pull
|
|
210
|
+
request that declares neither — `npm run changeset:check` is the same check,
|
|
211
|
+
locally.
|
|
212
|
+
|
|
213
|
+
**Merging to `main` opens a Version Packages pull request** carrying the version
|
|
214
|
+
bump and the rewritten `CHANGELOG.md`. That pull request _is_ the pending
|
|
215
|
+
release: as long as something is merged but unpublished, there is an open pull
|
|
216
|
+
request saying so. The previous, hand-driven process failed by leaving _nothing_
|
|
217
|
+
behind when its final step was forgotten (#4).
|
|
218
|
+
|
|
219
|
+
**Merging that publishes.** `changeset publish` puts the version on npm through
|
|
220
|
+
Trusted Publishing (OIDC — there is no `NPM_TOKEN`), tags the commit `v<version>`
|
|
221
|
+
and cuts the GitHub Release with the changelog section as its body. It publishes
|
|
222
|
+
only versions that are not already on the registry, so re-running it is a no-op;
|
|
223
|
+
`workflow_dispatch` on **Publish to npm** is the recovery path if a run fails
|
|
224
|
+
after versioning.
|
|
225
|
+
|
|
226
|
+
A changeset is also where a **raised dependency floor** gets recorded. 0.2.0 began
|
|
227
|
+
requiring `@heroiclands/content-build >= 0.15.0` and announced it nowhere; a
|
|
228
|
+
changeset is the place that now happens.
|
|
229
|
+
|
|
230
|
+
Below 1.0.0, `^0.x` never crosses a minor — a consumer on `^0.2.0` will not see
|
|
231
|
+
`0.3.0` until it bumps the pin deliberately, and Dependabot raises that as its own
|
|
232
|
+
pull request.
|
|
233
|
+
|
|
234
|
+
> After a successful publish, `npm view @heroiclands/package-build version` can
|
|
235
|
+
> report the _previous_ version for a minute or so. `dist-tags` is correct
|
|
236
|
+
> immediately, and is what the workflow prints.
|
|
237
|
+
|
|
103
238
|
## Licence
|
|
104
239
|
|
|
105
240
|
GPL-3.0-or-later.
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
4
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
5
|
+
*
|
|
6
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
7
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
8
|
+
*
|
|
9
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
10
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
11
|
+
*
|
|
12
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `package-build` command line — clean, stage, check, package, deploy.
|
|
17
|
+
*
|
|
18
|
+
* **Why this exists.** This package was library-only, so every consuming
|
|
19
|
+
* repository wrote a wrapper script per job: six of them in the Song of Heroic
|
|
20
|
+
* Lands repository, 441 lines that between them contained no logic. `clean.mjs`
|
|
21
|
+
* was 47 lines that computed a `repoRoot` from `import.meta.url`, read one
|
|
22
|
+
* flag, and made one call. `push-stage.mjs` hard-coded `packageKind: "systems"`
|
|
23
|
+
* and `packageId: "sohl"` beside a configuration that already declared both.
|
|
24
|
+
* Each copy had drifted from its sibling in the other repositories, because
|
|
25
|
+
* copies do.
|
|
26
|
+
*
|
|
27
|
+
* It is the same shape the configuration had before it became data: not logic,
|
|
28
|
+
* but the boilerplate a code file needs in order to state a literal. So the
|
|
29
|
+
* literals move into `content-build.config.yaml`'s reserved `packageBuild:`
|
|
30
|
+
* section, and the boilerplate lives here, once.
|
|
31
|
+
*
|
|
32
|
+
* **Every side effect lives in this file.** argv parsing, the environment,
|
|
33
|
+
* writing to the filesystem, and the process exit code. The library modules
|
|
34
|
+
* stay import-safe, so a consuming repository's build — or a test — can call
|
|
35
|
+
* them without any of it happening.
|
|
36
|
+
*
|
|
37
|
+
* The side effects that need *configuration* live inside the command handlers,
|
|
38
|
+
* never at module scope, so `--version` and `--help` answer in a directory with
|
|
39
|
+
* no configuration at all. Running an actual command still resolves it, and
|
|
40
|
+
* still fails loudly when it is missing.
|
|
41
|
+
*
|
|
42
|
+
* Usage:
|
|
43
|
+
* npx package-build clean [--distclean]
|
|
44
|
+
* npx package-build assets
|
|
45
|
+
* npx package-build lang check
|
|
46
|
+
* npx package-build release
|
|
47
|
+
* npx package-build deploy <stage>
|
|
48
|
+
*
|
|
49
|
+
* In a consuming repository, wrapped as npm scripts — SoHL spells them:
|
|
50
|
+
* npm run clean // → … clean
|
|
51
|
+
* npm run build:assets // → … assets
|
|
52
|
+
* npm run lint:lang // → … lang check
|
|
53
|
+
* npm run build:pack-release // → … release
|
|
54
|
+
* npm run push:qa // → … deploy qa
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import fs from "node:fs";
|
|
58
|
+
import path from "node:path";
|
|
59
|
+
import process from "node:process";
|
|
60
|
+
import { globSync } from "glob";
|
|
61
|
+
import yargs from "yargs";
|
|
62
|
+
import { hideBin } from "yargs/helpers";
|
|
63
|
+
|
|
64
|
+
import { loadPackageBuildConfig } from "../config.mjs";
|
|
65
|
+
import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
|
|
66
|
+
import { validateLangSource } from "../lang.mjs";
|
|
67
|
+
import { packRelease } from "../release.mjs";
|
|
68
|
+
import { deployStage } from "../deploy.mjs";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* This package's own version, for `--version`.
|
|
72
|
+
*
|
|
73
|
+
* Read from this package's manifest rather than left to yargs, which defaults
|
|
74
|
+
* to the *nearest* `package.json` walking up from the working directory —
|
|
75
|
+
* inside a consuming repository that is the consumer's, so the CLI would report
|
|
76
|
+
* the consumer's version instead of the toolchain's.
|
|
77
|
+
*
|
|
78
|
+
* @returns {string} The `version` field of this package's manifest.
|
|
79
|
+
*/
|
|
80
|
+
function ownVersion() {
|
|
81
|
+
return JSON.parse(
|
|
82
|
+
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
83
|
+
).version;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Report a failure the way a build should: one line, no stack, non-zero exit.
|
|
88
|
+
*
|
|
89
|
+
* @param {unknown} err - What went wrong.
|
|
90
|
+
* @returns {never}
|
|
91
|
+
*/
|
|
92
|
+
function die(err) {
|
|
93
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
94
|
+
console.error(`package-build: ${message}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Wrap a command handler so every failure is reported the same way.
|
|
100
|
+
*
|
|
101
|
+
* yargs' own `.fail()` sees a *synchronous* handler's throw but not an async
|
|
102
|
+
* one's rejection, so without this a `clean` failure printed one clean line and
|
|
103
|
+
* a `deploy` failure printed a stack trace. A build's diagnostics should not
|
|
104
|
+
* depend on whether the command it ran happened to await something.
|
|
105
|
+
*
|
|
106
|
+
* @param {(args: object) => unknown} run - The handler body.
|
|
107
|
+
* @returns {(args: object) => Promise<void>} The wrapped handler.
|
|
108
|
+
*/
|
|
109
|
+
function handler(run) {
|
|
110
|
+
return async (args) => {
|
|
111
|
+
try {
|
|
112
|
+
await run(args);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
die(err);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `clean` — remove this repository's build artifacts.
|
|
121
|
+
*
|
|
122
|
+
* The conventional artifact directories are the library's; a repository that
|
|
123
|
+
* generates more (a site's `content/`, `public/` and `resources/`) names them
|
|
124
|
+
* in `packageBuild.clean.extra` rather than reimplementing the walk, which is
|
|
125
|
+
* what every consumer's `clean.mjs` did.
|
|
126
|
+
*
|
|
127
|
+
* @returns {object} The yargs command module.
|
|
128
|
+
*/
|
|
129
|
+
function cleanCommand() {
|
|
130
|
+
return {
|
|
131
|
+
command: "clean",
|
|
132
|
+
describe: "Remove build artifacts",
|
|
133
|
+
builder: (y) =>
|
|
134
|
+
y.option("distclean", {
|
|
135
|
+
type: "boolean",
|
|
136
|
+
default: false,
|
|
137
|
+
describe: "Also remove node_modules",
|
|
138
|
+
}),
|
|
139
|
+
handler: handler((args) => {
|
|
140
|
+
const config = loadPackageBuildConfig();
|
|
141
|
+
const removed = cleanBuildArtifacts(config.rootDir, {
|
|
142
|
+
includeNodeModules: args.distclean,
|
|
143
|
+
extra: config.cleanExtra,
|
|
144
|
+
});
|
|
145
|
+
for (const dir of removed) console.log(`Removed ${dir}`);
|
|
146
|
+
if (!removed.length) console.log("Nothing to clean.");
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `assets` — stage the repository's static files into the package root.
|
|
153
|
+
*
|
|
154
|
+
* The table is data (`packageBuild.assets`). A repository that has to *change*
|
|
155
|
+
* a file on the way — SoHL rewrites each SVG's hard-coded fill so icons follow
|
|
156
|
+
* the Foundry theme — names a module in `packageBuild.assetTransform`, whose
|
|
157
|
+
* `transform(sourcePath)` returns replacement text or `null` to copy verbatim.
|
|
158
|
+
* That is the one genuine piece of code in the job, and it stays the
|
|
159
|
+
* repository's.
|
|
160
|
+
*
|
|
161
|
+
* @returns {object} The yargs command module.
|
|
162
|
+
*/
|
|
163
|
+
function assetsCommand() {
|
|
164
|
+
return {
|
|
165
|
+
command: "assets",
|
|
166
|
+
describe: "Stage static assets into the package root",
|
|
167
|
+
builder: (y) => y,
|
|
168
|
+
handler: handler(async () => {
|
|
169
|
+
const config = loadPackageBuildConfig();
|
|
170
|
+
if (!config.assets.length) {
|
|
171
|
+
console.log(
|
|
172
|
+
"package-build: no `packageBuild.assets` declared; nothing to stage.",
|
|
173
|
+
);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let transform;
|
|
178
|
+
if (config.assetTransform) {
|
|
179
|
+
const module = await import(
|
|
180
|
+
`file://${config.assetTransform}`
|
|
181
|
+
).catch((err) =>
|
|
182
|
+
die(
|
|
183
|
+
`cannot load \`packageBuild.assetTransform\` ` +
|
|
184
|
+
`(${config.assetTransform}): ${err.message}`,
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
transform = module.transform;
|
|
188
|
+
if (typeof transform !== "function") {
|
|
189
|
+
die(
|
|
190
|
+
`\`packageBuild.assetTransform\` ` +
|
|
191
|
+
`(${config.assetTransform}) exports no \`transform\` ` +
|
|
192
|
+
`function. It must export ` +
|
|
193
|
+
`\`transform(sourcePath) -> string | null\`.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// `to:` is relative to the staged package root, so a
|
|
199
|
+
// repository's table reads `lang`, not `build/stage/lang`.
|
|
200
|
+
const entries = config.assets.map(({ from, to }) => [
|
|
201
|
+
from,
|
|
202
|
+
path.join(config.stageDir, to),
|
|
203
|
+
]);
|
|
204
|
+
const { entries: count, files } = stageAssets(entries, {
|
|
205
|
+
cwd: config.rootDir,
|
|
206
|
+
transform,
|
|
207
|
+
});
|
|
208
|
+
console.log(
|
|
209
|
+
`✅ Static assets staged (${count} entries, ${files} files).`,
|
|
210
|
+
);
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `lang check` — verify every localization file survives `expandObject`.
|
|
217
|
+
*
|
|
218
|
+
* A dotted-prefix collision makes `foundry.utils.expandObject` throw, and
|
|
219
|
+
* Foundry then drops the whole translation file silently. The rule is the
|
|
220
|
+
* library's; the glob and any repository-specific guidance are data.
|
|
221
|
+
*
|
|
222
|
+
* @returns {object} The yargs command module.
|
|
223
|
+
*/
|
|
224
|
+
function langCommand() {
|
|
225
|
+
return {
|
|
226
|
+
command: "lang <action>",
|
|
227
|
+
describe: "Localization checks",
|
|
228
|
+
builder: (y) =>
|
|
229
|
+
y.positional("action", {
|
|
230
|
+
choices: ["check"],
|
|
231
|
+
describe: "check: verify the files are expandObject-safe",
|
|
232
|
+
}),
|
|
233
|
+
handler: handler(() => {
|
|
234
|
+
const config = loadPackageBuildConfig();
|
|
235
|
+
const files = globSync(config.langSources, {
|
|
236
|
+
cwd: config.rootDir,
|
|
237
|
+
absolute: true,
|
|
238
|
+
});
|
|
239
|
+
if (!files.length) {
|
|
240
|
+
die(
|
|
241
|
+
`no localization files matched ` +
|
|
242
|
+
`\`${config.langSources}\` under ${config.rootDir}.`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let total = 0;
|
|
247
|
+
for (const file of files.sort()) {
|
|
248
|
+
const relative = path.relative(config.rootDir, file);
|
|
249
|
+
for (const finding of validateLangSource(
|
|
250
|
+
fs.readFileSync(file, "utf8"),
|
|
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
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (total) {
|
|
265
|
+
if (config.langHelp) console.error(`\n${config.langHelp}`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
console.log(
|
|
269
|
+
`package-build: ${files.length} localization file(s) are ` +
|
|
270
|
+
`expandObject-safe.`,
|
|
271
|
+
);
|
|
272
|
+
}),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* `release` — zip the staged package for a GitHub release.
|
|
278
|
+
*
|
|
279
|
+
* The artifact name is derived from `packageKind`: Foundry installs a system
|
|
280
|
+
* from `system.json` and a module from `module.json`, so the kind already
|
|
281
|
+
* decides it and no repository states it.
|
|
282
|
+
*
|
|
283
|
+
* @returns {object} The yargs command module.
|
|
284
|
+
*/
|
|
285
|
+
function releaseCommand() {
|
|
286
|
+
return {
|
|
287
|
+
command: "release",
|
|
288
|
+
describe: "Package the staged build for release",
|
|
289
|
+
builder: (y) => y,
|
|
290
|
+
handler: handler(async () => {
|
|
291
|
+
const config = loadPackageBuildConfig();
|
|
292
|
+
const { zip, version, bytes } = await packRelease({
|
|
293
|
+
artifact: config.artifact,
|
|
294
|
+
});
|
|
295
|
+
console.log(
|
|
296
|
+
`✅ Packaged ${version} for release: ` +
|
|
297
|
+
`${path.relative(config.rootDir, zip)} ` +
|
|
298
|
+
`(${(bytes / 1024 / 1024).toFixed(1)} MB)`,
|
|
299
|
+
);
|
|
300
|
+
}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* `deploy <stage>` — push the staged package to a Foundry data directory.
|
|
306
|
+
*
|
|
307
|
+
* `packageKind` and `packageId` come from the shared configuration, where they
|
|
308
|
+
* were already declared. Every consumer's `push-stage.mjs` hard-coded them a
|
|
309
|
+
* second time, which is two places for one fact and exactly the drift this
|
|
310
|
+
* command removes.
|
|
311
|
+
*
|
|
312
|
+
* @returns {object} The yargs command module.
|
|
313
|
+
*/
|
|
314
|
+
function deployCommand() {
|
|
315
|
+
return {
|
|
316
|
+
command: "deploy <stage>",
|
|
317
|
+
describe: "Deploy the staged package to a stage",
|
|
318
|
+
builder: (y) =>
|
|
319
|
+
y.positional("stage", {
|
|
320
|
+
type: "string",
|
|
321
|
+
describe: "Target stage (e.g. dev, qa, prod, test)",
|
|
322
|
+
}),
|
|
323
|
+
handler: handler(async (args) => {
|
|
324
|
+
const config = loadPackageBuildConfig();
|
|
325
|
+
|
|
326
|
+
// Loaded here rather than at module scope: `--help` must answer in
|
|
327
|
+
// a repository that has no environment file at all.
|
|
328
|
+
const dotenv = await import("dotenv");
|
|
329
|
+
dotenv.config({
|
|
330
|
+
path: path.join(config.rootDir, ".env.local"),
|
|
331
|
+
quiet: true,
|
|
332
|
+
});
|
|
333
|
+
dotenv.config({
|
|
334
|
+
path: path.join(config.rootDir, ".env"),
|
|
335
|
+
quiet: true,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const { stage } = await deployStage({
|
|
339
|
+
stage: args.stage,
|
|
340
|
+
source: path.join(config.rootDir, config.stageDir),
|
|
341
|
+
packageKind: config.packageKind,
|
|
342
|
+
packageId: config.packageId,
|
|
343
|
+
prefix: config.envPrefix,
|
|
344
|
+
log: (message) => console.log(message),
|
|
345
|
+
});
|
|
346
|
+
console.log(`Deployed stage '${stage}' successfully.`);
|
|
347
|
+
}),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
yargs(hideBin(process.argv))
|
|
352
|
+
.scriptName("package-build")
|
|
353
|
+
.command(cleanCommand())
|
|
354
|
+
.command(assetsCommand())
|
|
355
|
+
.command(langCommand())
|
|
356
|
+
.command(releaseCommand())
|
|
357
|
+
.command(deployCommand())
|
|
358
|
+
.demandCommand(1, "Name a command.")
|
|
359
|
+
.strict()
|
|
360
|
+
.version(ownVersion())
|
|
361
|
+
.help()
|
|
362
|
+
.alias("help", "h")
|
|
363
|
+
.fail((message, err) => die(err ?? message)).argv;
|
package/config.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
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 per-repository configuration this package reads.
|
|
16
|
+
*
|
|
17
|
+
* **One repository, one configuration file.** A repository already declares
|
|
18
|
+
* itself in `content-build.config.yaml`, and two of the values this package
|
|
19
|
+
* needs — `packageKind` and `foundryPackage` — are already in it. A second file
|
|
20
|
+
* would restate them, which is two places for one fact; that is precisely what
|
|
21
|
+
* every consumer's `push-stage.mjs` did, hard-coding `packageKind: "systems"`
|
|
22
|
+
* and `packageId: "sohl"` beside a config that already said both.
|
|
23
|
+
*
|
|
24
|
+
* So this package reads the *same* file, through content-build's loader, and
|
|
25
|
+
* takes its own settings from the reserved `packageBuild:` section. The two
|
|
26
|
+
* packages split by **input** — content-build reads the content tree, this one
|
|
27
|
+
* reads `lang/`, `styles/`, `src/`, the assets and the manifest template — and
|
|
28
|
+
* neither validates the other's keys. content-build checks that the section is
|
|
29
|
+
* a mapping and hands it back frozen; everything inside it is validated here.
|
|
30
|
+
*
|
|
31
|
+
* **The dependency runs one way.** This package depends on content-build;
|
|
32
|
+
* content-build must never depend on this one. It is the same direction the
|
|
33
|
+
* loader already implies, and keeping it means content-build stays usable by a
|
|
34
|
+
* repository that ships content and no Foundry package at all.
|
|
35
|
+
*
|
|
36
|
+
* ```yaml
|
|
37
|
+
* # content-build.config.yaml
|
|
38
|
+
* packageKind: systems # read from the top level, not restated below
|
|
39
|
+
* foundryPackage: sohl
|
|
40
|
+
*
|
|
41
|
+
* packageBuild:
|
|
42
|
+
* assets:
|
|
43
|
+
* - { from: lang, to: lang }
|
|
44
|
+
* - { from: assets/icons, to: assets/icons }
|
|
45
|
+
* assetTransform: ./utils/svg-theme.mjs
|
|
46
|
+
* stageDir: build/stage
|
|
47
|
+
* clean:
|
|
48
|
+
* extra: [site/content, site/public]
|
|
49
|
+
* lang:
|
|
50
|
+
* sources: lang/*.json
|
|
51
|
+
* deploy:
|
|
52
|
+
* envPrefix: SOHL
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @module
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import path from "node:path";
|
|
59
|
+
import { loadPackConfig } from "@heroiclands/content-build/engine/pack-config";
|
|
60
|
+
|
|
61
|
+
/** Keys the reserved section may declare. */
|
|
62
|
+
const SECTION_KEYS = [
|
|
63
|
+
"stageDir",
|
|
64
|
+
"assets",
|
|
65
|
+
"assetTransform",
|
|
66
|
+
"clean",
|
|
67
|
+
"lang",
|
|
68
|
+
"deploy",
|
|
69
|
+
"release",
|
|
70
|
+
];
|
|
71
|
+
const ASSET_KEYS = ["from", "to"];
|
|
72
|
+
const CLEAN_KEYS = ["extra"];
|
|
73
|
+
const LANG_KEYS = ["sources", "help"];
|
|
74
|
+
const DEPLOY_KEYS = ["envPrefix"];
|
|
75
|
+
const RELEASE_KEYS = ["artifact"];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The artifact name each package kind ships, so no repository states it.
|
|
79
|
+
*
|
|
80
|
+
* Foundry installs a system from `system.json` and a module from `module.json`;
|
|
81
|
+
* the kind already says which, so `pack-release.mjs` passing
|
|
82
|
+
* `{ artifact: "system" }` by hand was restating `packageKind`.
|
|
83
|
+
*/
|
|
84
|
+
const ARTIFACT_OF_KIND = Object.freeze({
|
|
85
|
+
systems: "system",
|
|
86
|
+
modules: "module",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} where - Dotted path of the offending key.
|
|
91
|
+
* @param {string} problem - What is wrong with it.
|
|
92
|
+
* @returns {never}
|
|
93
|
+
*/
|
|
94
|
+
function fail(where, problem) {
|
|
95
|
+
throw new TypeError(`package-build config: \`${where}\` ${problem}.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {unknown} value
|
|
100
|
+
* @returns {boolean} Whether it is a plain mapping.
|
|
101
|
+
*/
|
|
102
|
+
function isMapping(value) {
|
|
103
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {Record<string, unknown>} object - The mapping to check.
|
|
108
|
+
* @param {readonly string[]} allowed - The keys it may declare.
|
|
109
|
+
* @param {string} prefix - Dotted path prefix for the error.
|
|
110
|
+
*/
|
|
111
|
+
function rejectUnknownKeys(object, allowed, prefix) {
|
|
112
|
+
for (const key of Object.keys(object)) {
|
|
113
|
+
if (!allowed.includes(key)) {
|
|
114
|
+
fail(
|
|
115
|
+
`${prefix}${key}`,
|
|
116
|
+
`is not a recognised key (expected one of: ${allowed.join(", ")})`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {unknown} value
|
|
124
|
+
* @param {string} where
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
function requireNonEmptyString(value, where) {
|
|
128
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
129
|
+
fail(where, "must be a non-empty string");
|
|
130
|
+
}
|
|
131
|
+
return /** @type {string} */ (value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One staging copy: a source path in the repository, and where it lands under
|
|
136
|
+
* the staged package root.
|
|
137
|
+
*
|
|
138
|
+
* @typedef {object} AssetSpec
|
|
139
|
+
* @property {string} from Source path, relative to the repository root.
|
|
140
|
+
* @property {string} to Destination, relative to the staged package root.
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {unknown} value
|
|
145
|
+
* @param {number} index
|
|
146
|
+
* @returns {Readonly<AssetSpec>}
|
|
147
|
+
*/
|
|
148
|
+
function normalizeAsset(value, index) {
|
|
149
|
+
const where = `packageBuild.assets[${index}]`;
|
|
150
|
+
if (!isMapping(value)) fail(where, "must be a mapping");
|
|
151
|
+
const asset = /** @type {Record<string, unknown>} */ (value);
|
|
152
|
+
rejectUnknownKeys(asset, ASSET_KEYS, `${where}.`);
|
|
153
|
+
return Object.freeze({
|
|
154
|
+
from: requireNonEmptyString(asset.from, `${where}.from`),
|
|
155
|
+
to: requireNonEmptyString(asset.to, `${where}.to`),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The resolved `packageBuild` section, every optional half filled in.
|
|
161
|
+
*
|
|
162
|
+
* @typedef {object} PackageBuildConfig
|
|
163
|
+
* @property {string} rootDir The repository root, from content-build.
|
|
164
|
+
* @property {string} packageKind `systems` or `modules`.
|
|
165
|
+
* @property {string} packageId The Foundry package id.
|
|
166
|
+
* @property {string} artifact Derived: `system` or `module`.
|
|
167
|
+
* @property {string} stageDir The staged package root, relative to
|
|
168
|
+
* `rootDir`. Every asset `to:` lands under it.
|
|
169
|
+
* @property {readonly Readonly<AssetSpec>[]} assets
|
|
170
|
+
* @property {string|null} assetTransform Module to load a `transform` from,
|
|
171
|
+
* resolved against `rootDir`. `null` when
|
|
172
|
+
* the repository stages assets verbatim.
|
|
173
|
+
* @property {readonly string[]} cleanExtra Directories to remove beyond the
|
|
174
|
+
* conventional build artifacts.
|
|
175
|
+
* @property {string} langSources Glob for the localization files to check.
|
|
176
|
+
* @property {string|null} langHelp Extra guidance printed after a failure.
|
|
177
|
+
* @property {string} envPrefix Prefix of the deploy environment variables.
|
|
178
|
+
*/
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve a package-build configuration from an already-loaded shared one.
|
|
182
|
+
*
|
|
183
|
+
* Separate from {@link loadPackageBuildConfig} because this half is pure: it
|
|
184
|
+
* reads no file and touches no environment, so the validation rules can be
|
|
185
|
+
* described directly by a test instead of through a fixture repository on
|
|
186
|
+
* disk. {@link loadPackageBuildConfig} is the same function with the loading
|
|
187
|
+
* put back.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} shared - The resolved content-build configuration.
|
|
190
|
+
* @returns {Readonly<PackageBuildConfig>} The frozen configuration.
|
|
191
|
+
* @throws {TypeError} When the reserved section declares something malformed.
|
|
192
|
+
*/
|
|
193
|
+
export function resolvePackageBuildConfig(shared) {
|
|
194
|
+
const section = /** @type {Record<string, unknown>} */ (
|
|
195
|
+
shared.packageBuild ?? {}
|
|
196
|
+
);
|
|
197
|
+
rejectUnknownKeys(section, SECTION_KEYS, "packageBuild.");
|
|
198
|
+
|
|
199
|
+
if (section.assets !== undefined && !Array.isArray(section.assets)) {
|
|
200
|
+
fail("packageBuild.assets", "must be a list");
|
|
201
|
+
}
|
|
202
|
+
const assets = (section.assets ?? []).map(normalizeAsset);
|
|
203
|
+
|
|
204
|
+
const clean = section.clean ?? {};
|
|
205
|
+
if (!isMapping(clean)) fail("packageBuild.clean", "must be a mapping");
|
|
206
|
+
rejectUnknownKeys(
|
|
207
|
+
/** @type {Record<string, unknown>} */ (clean),
|
|
208
|
+
CLEAN_KEYS,
|
|
209
|
+
"packageBuild.clean.",
|
|
210
|
+
);
|
|
211
|
+
const extra = /** @type {Record<string, unknown>} */ (clean).extra ?? [];
|
|
212
|
+
if (!Array.isArray(extra)) {
|
|
213
|
+
fail("packageBuild.clean.extra", "must be a list");
|
|
214
|
+
}
|
|
215
|
+
const cleanExtra = extra.map((dir, i) =>
|
|
216
|
+
requireNonEmptyString(dir, `packageBuild.clean.extra[${i}]`),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const lang = section.lang ?? {};
|
|
220
|
+
if (!isMapping(lang)) fail("packageBuild.lang", "must be a mapping");
|
|
221
|
+
rejectUnknownKeys(
|
|
222
|
+
/** @type {Record<string, unknown>} */ (lang),
|
|
223
|
+
LANG_KEYS,
|
|
224
|
+
"packageBuild.lang.",
|
|
225
|
+
);
|
|
226
|
+
const langInput = /** @type {Record<string, unknown>} */ (lang);
|
|
227
|
+
|
|
228
|
+
const deploy = section.deploy ?? {};
|
|
229
|
+
if (!isMapping(deploy)) fail("packageBuild.deploy", "must be a mapping");
|
|
230
|
+
rejectUnknownKeys(
|
|
231
|
+
/** @type {Record<string, unknown>} */ (deploy),
|
|
232
|
+
DEPLOY_KEYS,
|
|
233
|
+
"packageBuild.deploy.",
|
|
234
|
+
);
|
|
235
|
+
const deployInput = /** @type {Record<string, unknown>} */ (deploy);
|
|
236
|
+
|
|
237
|
+
const release = section.release ?? {};
|
|
238
|
+
if (!isMapping(release)) fail("packageBuild.release", "must be a mapping");
|
|
239
|
+
rejectUnknownKeys(
|
|
240
|
+
/** @type {Record<string, unknown>} */ (release),
|
|
241
|
+
RELEASE_KEYS,
|
|
242
|
+
"packageBuild.release.",
|
|
243
|
+
);
|
|
244
|
+
const releaseInput = /** @type {Record<string, unknown>} */ (release);
|
|
245
|
+
|
|
246
|
+
return Object.freeze({
|
|
247
|
+
rootDir: shared.rootDir,
|
|
248
|
+
// Where the package is assembled before it is zipped or deployed. Every
|
|
249
|
+
// asset destination is relative to it, so a repository's table says
|
|
250
|
+
// `lang`, not `build/stage/lang` — the latter is what each consumer's
|
|
251
|
+
// `copy-assets.mjs` spelled out on every row.
|
|
252
|
+
stageDir:
|
|
253
|
+
section.stageDir === undefined ?
|
|
254
|
+
"build/stage"
|
|
255
|
+
: requireNonEmptyString(
|
|
256
|
+
section.stageDir,
|
|
257
|
+
"packageBuild.stageDir",
|
|
258
|
+
),
|
|
259
|
+
packageKind: shared.packageKind,
|
|
260
|
+
packageId: shared.foundryPackage,
|
|
261
|
+
// Derived from the kind, which already decides it. Stating it was one
|
|
262
|
+
// more literal every consumer's release script carried.
|
|
263
|
+
artifact:
|
|
264
|
+
releaseInput.artifact === undefined ?
|
|
265
|
+
ARTIFACT_OF_KIND[
|
|
266
|
+
/** @type {"systems"|"modules"} */ (shared.packageKind)
|
|
267
|
+
]
|
|
268
|
+
: requireNonEmptyString(
|
|
269
|
+
releaseInput.artifact,
|
|
270
|
+
"packageBuild.release.artifact",
|
|
271
|
+
),
|
|
272
|
+
assets: Object.freeze(assets),
|
|
273
|
+
assetTransform:
|
|
274
|
+
section.assetTransform === undefined ?
|
|
275
|
+
null
|
|
276
|
+
: path.resolve(
|
|
277
|
+
shared.rootDir,
|
|
278
|
+
requireNonEmptyString(
|
|
279
|
+
section.assetTransform,
|
|
280
|
+
"packageBuild.assetTransform",
|
|
281
|
+
),
|
|
282
|
+
),
|
|
283
|
+
cleanExtra: Object.freeze(cleanExtra),
|
|
284
|
+
langSources:
|
|
285
|
+
langInput.sources === undefined ?
|
|
286
|
+
"lang/*.json"
|
|
287
|
+
: requireNonEmptyString(
|
|
288
|
+
langInput.sources,
|
|
289
|
+
"packageBuild.lang.sources",
|
|
290
|
+
),
|
|
291
|
+
langHelp:
|
|
292
|
+
langInput.help === undefined ?
|
|
293
|
+
null
|
|
294
|
+
: requireNonEmptyString(langInput.help, "packageBuild.lang.help"),
|
|
295
|
+
envPrefix:
|
|
296
|
+
deployInput.envPrefix === undefined ?
|
|
297
|
+
"SOHL"
|
|
298
|
+
: requireNonEmptyString(
|
|
299
|
+
deployInput.envPrefix,
|
|
300
|
+
"packageBuild.deploy.envPrefix",
|
|
301
|
+
),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The repository's resolved package-build configuration.
|
|
307
|
+
*
|
|
308
|
+
* Read on call rather than at import, exactly as content-build resolves its
|
|
309
|
+
* own: importing a module of this package must not require a configuration to
|
|
310
|
+
* exist anywhere above it.
|
|
311
|
+
*
|
|
312
|
+
* @returns {Readonly<PackageBuildConfig>} The frozen configuration.
|
|
313
|
+
* @throws {TypeError} When there is no configuration, or the reserved section
|
|
314
|
+
* declares something malformed.
|
|
315
|
+
*/
|
|
316
|
+
export function loadPackageBuildConfig() {
|
|
317
|
+
return resolvePackageBuildConfig(loadPackConfig());
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
7
7
|
"main": "./index.mjs",
|
|
@@ -39,7 +39,11 @@
|
|
|
39
39
|
"types": "./types/text.d.mts",
|
|
40
40
|
"import": "./text.mjs"
|
|
41
41
|
},
|
|
42
|
-
"./package.json": "./package.json"
|
|
42
|
+
"./package.json": "./package.json",
|
|
43
|
+
"./config": {
|
|
44
|
+
"types": "./types/config.d.mts",
|
|
45
|
+
"import": "./config.mjs"
|
|
46
|
+
}
|
|
43
47
|
},
|
|
44
48
|
"files": [
|
|
45
49
|
"bundle.mjs",
|
|
@@ -51,7 +55,10 @@
|
|
|
51
55
|
"stage.mjs",
|
|
52
56
|
"text.mjs",
|
|
53
57
|
"types",
|
|
54
|
-
"
|
|
58
|
+
"CHANGELOG.md",
|
|
59
|
+
"README.md",
|
|
60
|
+
"bin",
|
|
61
|
+
"config.mjs"
|
|
55
62
|
],
|
|
56
63
|
"scripts": {
|
|
57
64
|
"test": "vitest run",
|
|
@@ -60,14 +67,21 @@
|
|
|
60
67
|
"build:types": "tsc -p tsconfig.dts.json",
|
|
61
68
|
"format": "prettier --write .",
|
|
62
69
|
"format:check": "prettier --check .",
|
|
70
|
+
"changeset": "changeset",
|
|
71
|
+
"changeset:check": "changeset status --since=origin/main",
|
|
63
72
|
"prepare": "git config core.hooksPath .githooks || true"
|
|
64
73
|
},
|
|
65
74
|
"dependencies": {
|
|
75
|
+
"@heroiclands/content-build": "^0.15.0",
|
|
66
76
|
"acorn": "^8.18.0",
|
|
67
77
|
"archiver": "^8.0.0",
|
|
68
|
-
"
|
|
78
|
+
"dotenv": "^17.2.3",
|
|
79
|
+
"glob": "^11.0.3",
|
|
80
|
+
"ssh2-sftp-client": "^12.1.1",
|
|
81
|
+
"yargs": "^18.1.0"
|
|
69
82
|
},
|
|
70
83
|
"devDependencies": {
|
|
84
|
+
"@changesets/cli": "^3.0.0",
|
|
71
85
|
"@types/node": "^26.2.0",
|
|
72
86
|
"prettier": "^3.9.6",
|
|
73
87
|
"typescript": "^6.0.3",
|
|
@@ -95,5 +109,8 @@
|
|
|
95
109
|
},
|
|
96
110
|
"bugs": {
|
|
97
111
|
"url": "https://github.com/HeroicLands/package-build/issues"
|
|
112
|
+
},
|
|
113
|
+
"bin": {
|
|
114
|
+
"package-build": "./bin/package-build.mjs"
|
|
98
115
|
}
|
|
99
116
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resolved `packageBuild` section, every optional half filled in.
|
|
3
|
+
*
|
|
4
|
+
* @typedef {object} PackageBuildConfig
|
|
5
|
+
* @property {string} rootDir The repository root, from content-build.
|
|
6
|
+
* @property {string} packageKind `systems` or `modules`.
|
|
7
|
+
* @property {string} packageId The Foundry package id.
|
|
8
|
+
* @property {string} artifact Derived: `system` or `module`.
|
|
9
|
+
* @property {string} stageDir The staged package root, relative to
|
|
10
|
+
* `rootDir`. Every asset `to:` lands under it.
|
|
11
|
+
* @property {readonly Readonly<AssetSpec>[]} assets
|
|
12
|
+
* @property {string|null} assetTransform Module to load a `transform` from,
|
|
13
|
+
* resolved against `rootDir`. `null` when
|
|
14
|
+
* the repository stages assets verbatim.
|
|
15
|
+
* @property {readonly string[]} cleanExtra Directories to remove beyond the
|
|
16
|
+
* conventional build artifacts.
|
|
17
|
+
* @property {string} langSources Glob for the localization files to check.
|
|
18
|
+
* @property {string|null} langHelp Extra guidance printed after a failure.
|
|
19
|
+
* @property {string} envPrefix Prefix of the deploy environment variables.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a package-build configuration from an already-loaded shared one.
|
|
23
|
+
*
|
|
24
|
+
* Separate from {@link loadPackageBuildConfig} because this half is pure: it
|
|
25
|
+
* reads no file and touches no environment, so the validation rules can be
|
|
26
|
+
* described directly by a test instead of through a fixture repository on
|
|
27
|
+
* disk. {@link loadPackageBuildConfig} is the same function with the loading
|
|
28
|
+
* put back.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} shared - The resolved content-build configuration.
|
|
31
|
+
* @returns {Readonly<PackageBuildConfig>} The frozen configuration.
|
|
32
|
+
* @throws {TypeError} When the reserved section declares something malformed.
|
|
33
|
+
*/
|
|
34
|
+
export function resolvePackageBuildConfig(shared: object): Readonly<PackageBuildConfig>;
|
|
35
|
+
/**
|
|
36
|
+
* The repository's resolved package-build configuration.
|
|
37
|
+
*
|
|
38
|
+
* Read on call rather than at import, exactly as content-build resolves its
|
|
39
|
+
* own: importing a module of this package must not require a configuration to
|
|
40
|
+
* exist anywhere above it.
|
|
41
|
+
*
|
|
42
|
+
* @returns {Readonly<PackageBuildConfig>} The frozen configuration.
|
|
43
|
+
* @throws {TypeError} When there is no configuration, or the reserved section
|
|
44
|
+
* declares something malformed.
|
|
45
|
+
*/
|
|
46
|
+
export function loadPackageBuildConfig(): Readonly<PackageBuildConfig>;
|
|
47
|
+
/**
|
|
48
|
+
* One staging copy: a source path in the repository, and where it lands under
|
|
49
|
+
* the staged package root.
|
|
50
|
+
*/
|
|
51
|
+
export type AssetSpec = {
|
|
52
|
+
/**
|
|
53
|
+
* Source path, relative to the repository root.
|
|
54
|
+
*/
|
|
55
|
+
from: string;
|
|
56
|
+
/**
|
|
57
|
+
* Destination, relative to the staged package root.
|
|
58
|
+
*/
|
|
59
|
+
to: string;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The resolved `packageBuild` section, every optional half filled in.
|
|
63
|
+
*/
|
|
64
|
+
export type PackageBuildConfig = {
|
|
65
|
+
/**
|
|
66
|
+
* The repository root, from content-build.
|
|
67
|
+
*/
|
|
68
|
+
rootDir: string;
|
|
69
|
+
/**
|
|
70
|
+
* `systems` or `modules`.
|
|
71
|
+
*/
|
|
72
|
+
packageKind: string;
|
|
73
|
+
/**
|
|
74
|
+
* The Foundry package id.
|
|
75
|
+
*/
|
|
76
|
+
packageId: string;
|
|
77
|
+
/**
|
|
78
|
+
* Derived: `system` or `module`.
|
|
79
|
+
*/
|
|
80
|
+
artifact: string;
|
|
81
|
+
/**
|
|
82
|
+
* The staged package root, relative to
|
|
83
|
+
* `rootDir`. Every asset `to:` lands under it.
|
|
84
|
+
*/
|
|
85
|
+
stageDir: string;
|
|
86
|
+
assets: readonly Readonly<AssetSpec>[];
|
|
87
|
+
/**
|
|
88
|
+
* Module to load a `transform` from,
|
|
89
|
+
* resolved against `rootDir`. `null` when
|
|
90
|
+
* the repository stages assets verbatim.
|
|
91
|
+
*/
|
|
92
|
+
assetTransform: string | null;
|
|
93
|
+
/**
|
|
94
|
+
* Directories to remove beyond the
|
|
95
|
+
* conventional build artifacts.
|
|
96
|
+
*/
|
|
97
|
+
cleanExtra: readonly string[];
|
|
98
|
+
/**
|
|
99
|
+
* Glob for the localization files to check.
|
|
100
|
+
*/
|
|
101
|
+
langSources: string;
|
|
102
|
+
/**
|
|
103
|
+
* Extra guidance printed after a failure.
|
|
104
|
+
*/
|
|
105
|
+
langHelp: string | null;
|
|
106
|
+
/**
|
|
107
|
+
* Prefix of the deploy environment variables.
|
|
108
|
+
*/
|
|
109
|
+
envPrefix: string;
|
|
110
|
+
};
|