@heroiclands/package-build 23.4.0 → 23.5.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +29 -25
  3. package/assets/images/provenance.yaml +1 -0
  4. package/bin/package-build.mjs +259 -28
  5. package/ci/prettier.mjs +73 -0
  6. package/config.mjs +10 -0
  7. package/container.mjs +232 -1
  8. package/content-config.mjs +5 -1
  9. package/docs/api.md +100 -52
  10. package/docs/authoring/assets.md +16 -1
  11. package/docs/commands.md +154 -7
  12. package/docs/configuration.md +39 -2
  13. package/docs/content-format.md +30 -6
  14. package/docs/guides/e2e.md +38 -0
  15. package/docs/guides/icons.md +5 -0
  16. package/docs/guides/release-and-deploy.md +36 -1
  17. package/docs/reference/format-details.md +218 -50
  18. package/docs/reference/note-types.md +71 -71
  19. package/engine/asset-index.mjs +63 -22
  20. package/engine/asset-references.mjs +355 -0
  21. package/engine/asset-shipping.mjs +363 -0
  22. package/engine/asset-types.mjs +70 -0
  23. package/engine/base-compiler.mjs +24 -1
  24. package/engine/cdn-publish.mjs +618 -0
  25. package/engine/index.mjs +6 -0
  26. package/engine/note-vocabulary.mjs +40 -2
  27. package/engine/pathnames.mjs +21 -10
  28. package/engine/rank-ladder.mjs +40 -16
  29. package/engine/scenes.mjs +1 -1
  30. package/engine/standing-terms.mjs +3 -1
  31. package/package.json +4 -3
  32. package/packagebuild-metadata.jsonl +13 -13
  33. package/release.mjs +69 -7
  34. package/sohl/actors.mjs +12 -4
  35. package/sohl/default-item-art.mjs +23 -19
  36. package/types/config.d.mts +7 -0
  37. package/types/container.d.mts +137 -1
  38. package/types/engine/asset-index.d.mts +3 -2
  39. package/types/engine/asset-references.d.mts +136 -0
  40. package/types/engine/asset-shipping.d.mts +240 -0
  41. package/types/engine/asset-types.d.mts +36 -0
  42. package/types/engine/base-compiler.d.mts +7 -1
  43. package/types/engine/cdn-publish.d.mts +311 -0
  44. package/types/engine/index.d.mts +2 -0
  45. package/types/engine/pathnames.d.mts +2 -1
  46. package/types/engine/rank-ladder.d.mts +25 -6
  47. package/types/release.d.mts +5 -3
  48. package/types/sohl/default-item-art.d.mts +2 -1
package/docs/commands.md CHANGED
@@ -58,7 +58,7 @@ The command entries below list only their own options.
58
58
  the walk up from the working directory the CLI otherwise does — the only
59
59
  environment variable the toolchain itself reads for configuration
60
60
  resolution. `deploy`, `container` and `e2e` additionally read `FOUNDRYVTT_*`,
61
- documented where each applies.
61
+ and `cdn publish` reads `CDN_*`, documented where each applies.
62
62
 
63
63
  An option name recurring across commands is not a recurring meaning —
64
64
  `--out`, `--check`, `--from` and `--strict` each carry their own default and
@@ -300,6 +300,16 @@ names a module in `packageBuild.assetTransform`, exporting
300
300
  anything else replaces its content. Reads every `from` path; writes into
301
301
  `packageBuild.stageDir`.
302
302
 
303
+ Then stages the package's pictures itself. Every file under `assets/icons/`
304
+ and `assets/images/` whose provenance says `ai: false` and whose licence is on
305
+ the shipping list (the default list plus `packageBuild.shippingLicenses`) is copied flat to `icons/<basename>` or `images/<basename>`
306
+ at the package root, through the same transform, and the package's asset set,
307
+ `asset-sets/default.json`, lists what was staged. Everything else stays in the repository. See
308
+ [what a package ships](reference/format-details.md#what-a-package-ships).
309
+ A `packageBuild.assets` row that copies a picture root, a directory inside
310
+ one, or a directory holding one is refused, because it would ship every file
311
+ unfiltered.
312
+
303
313
  **OPTIONS**
304
314
 
305
315
  None.
@@ -307,15 +317,20 @@ None.
307
317
  **EXIT STATUS**
308
318
 
309
319
  1 if `packageBuild.assetTransform` is declared but cannot be loaded, or
310
- exports no `transform` function. 1 on any other thrown error. Otherwise 0 —
311
- including when `packageBuild.assets` is empty, which logs and stages nothing
312
- rather than failing.
320
+ exports no `transform` function. 1 when a row copies a picture root, and 1 on
321
+ any asset finding — a file no provenance record covers, a record missing
322
+ `attribution`, `license` or `ai`, two files claiming one address — with
323
+ nothing staged. 1 on any other thrown error. Otherwise 0.
313
324
 
314
325
  **EXAMPLES**
315
326
 
316
327
  ```
317
328
  $ package-build assets
318
- ✅ Static assets staged (1 entries, 1 files).
329
+ Pictures: 4 staged, 2 left out by provenance.
330
+ ✅ Static assets staged (2 entries, 9 files).
331
+
332
+ $ package-build assets
333
+ package-build.config.yaml:14:17: error: `packageBuild.assets[6]` copies `assets/images`, which reaches assets/images/ — the stage copies the icon and image roots itself, flat and filtered by each file's provenance, so a row copying them would ship every file unfiltered. Remove the row
319
334
  ```
320
335
 
321
336
  **SEE ALSO**
@@ -1138,7 +1153,9 @@ states it a second time. It also publishes the content index the manifest
1138
1153
  advertises (`flags.metadataUrl`) and, when the stage carries one,
1139
1154
  `schema.json` — a repository that names `build/schema.json` in
1140
1155
  `packageBuild.assets` gets it released beside the archive; one that does not
1141
- gets none. When the content tree holds notes beyond its homepage, it
1156
+ gets none. When the stage carries `asset-sets/default.json`, it writes
1157
+ `icons.zip` and `images.zip` too, one per picture root the stage holds, each
1158
+ with that set. When the content tree holds notes beyond its homepage, it
1142
1159
  also builds the content-tree book (see `package-build pdf`) and reports it
1143
1160
  alongside the archive; `--no-pdf` skips that step for a release that has a
1144
1161
  tree but does not want the book this time. A book that fails to build is
@@ -1246,6 +1263,134 @@ Deployed stage 'test' successfully.
1246
1263
  `package-build container <stage> <action>`, `package-build e2e <action>`,
1247
1264
  [Configuration](configuration.md).
1248
1265
 
1266
+ ### `package-build cdn publish`
1267
+
1268
+ **NAME**
1269
+
1270
+ Upload every picture the package has to the CDN, and publish the art the
1271
+ package leaves out as a named asset set.
1272
+
1273
+ **SYNOPSIS**
1274
+
1275
+ ```
1276
+ package-build cdn publish --set <file> --name <display name> [--dry-run]
1277
+ ```
1278
+
1279
+ **DESCRIPTION**
1280
+
1281
+ The one writer to `<site.assets>/<contentPackage>/`. It is run by hand, or by
1282
+ a manually dispatched workflow; no build or release runs it.
1283
+
1284
+ Every icon and image under the package's picture roots is uploaded flat, as
1285
+ `icons/<basename>` or `images/<basename>` — shipped and left-out pictures
1286
+ alike, because the website draws every one of them from the same address the
1287
+ site build gives it. `packageBuild.assetTransform` runs on each file exactly
1288
+ as `package-build assets` runs it, so a picture on the CDN holds the bytes the
1289
+ staged package holds. SVG is served as `image/svg+xml`.
1290
+
1291
+ The bucket is served at `site.assets`, so an object's key is its path below
1292
+ that address: `thalorna/images/thorn.webp` is
1293
+ `https://cdn.heroiclands.org/thalorna/images/thorn.webp`.
1294
+
1295
+ `--set` names the set file, written at `<site.assets>/<contentPackage>/<file>`.
1296
+ It lists only the pictures the package leaves out — an address a set does not
1297
+ list resolves to the shipped copy — chosen by the same rule the stage applies,
1298
+ `packageBuild.shippingLicenses` included. Its shape is the shipped set's, with
1299
+ each `source` the file's absolute CDN address:
1300
+
1301
+ ```json
1302
+ {
1303
+ "name": "Thalorna — illustrated",
1304
+ "package": "thalorna",
1305
+ "assets": [
1306
+ {
1307
+ "address": "thalorna-none-image-thorn",
1308
+ "source": "https://cdn.heroiclands.org/thalorna/images/thorn.webp",
1309
+ "target": "images/thorn.webp",
1310
+ "size": 48213
1311
+ }
1312
+ ]
1313
+ }
1314
+ ```
1315
+
1316
+ Several sets may sit side by side under one package, each with its own file
1317
+ name. `default.json` is refused: it is the shipped set's name.
1318
+
1319
+ A picture whose object already exists at the same size is skipped, and the set
1320
+ file is skipped when its bytes are unchanged, so a second run uploads nothing.
1321
+ A picture that fails to upload is reported and the rest carry on; the set file
1322
+ is then withheld, since it would list a file the CDN does not hold.
1323
+
1324
+ **Refusals and reports.** A file under `icons/` or `images/` whose name is no
1325
+ address — a basename that is not a shortcode, or two files claiming one — and
1326
+ a provenance record that cannot be read stop the run before anything is
1327
+ uploaded, each reported against its file. A note naming a picture the package
1328
+ does not have, and a note naming left-out art that is not on the CDN when the
1329
+ run ends, are warnings at the note's line and column: an art slot under
1330
+ `data:`, an embed, or a body image.
1331
+
1332
+ **Credentials.** The store is an S3-compatible bucket — Cloudflare R2 for
1333
+ `cdn.heroiclands.org` — signed with AWS Signature Version 4. Its settings are
1334
+ read from the environment, after `.env.local` and `.env` are loaded:
1335
+
1336
+ | Variable | What it is |
1337
+ | ----------------------- | ------------------------------------------------------------------------- |
1338
+ | `CDN_ENDPOINT` | The S3 endpoint; for R2, `https://<account id>.r2.cloudflarestorage.com`. |
1339
+ | `CDN_BUCKET` | The bucket served at `site.assets`. |
1340
+ | `CDN_ACCESS_KEY_ID` | The access key id of a token that can write the bucket. |
1341
+ | `CDN_SECRET_ACCESS_KEY` | That token's secret. Never printed. |
1342
+
1343
+ Setting some but not all of them is an error naming the missing ones. With
1344
+ none set, `--dry-run` compares against the public CDN at `site.assets`
1345
+ instead, read-only, and an upload is refused.
1346
+
1347
+ A workflow calls the command the same way, passing the four values as named
1348
+ secrets in the step's environment:
1349
+
1350
+ ```yaml
1351
+ - run: npx package-build cdn publish --set ai-art.json --name "Thalorna — illustrated"
1352
+ env:
1353
+ CDN_ENDPOINT: ${{ secrets.CDN_ENDPOINT }}
1354
+ CDN_BUCKET: ${{ secrets.CDN_BUCKET }}
1355
+ CDN_ACCESS_KEY_ID: ${{ secrets.CDN_ACCESS_KEY_ID }}
1356
+ CDN_SECRET_ACCESS_KEY: ${{ secrets.CDN_SECRET_ACCESS_KEY }}
1357
+ ```
1358
+
1359
+ **OPTIONS**
1360
+
1361
+ | Option | Type | Default | Description |
1362
+ | ----------- | ------- | ------- | --------------------------------------------------------------------------- |
1363
+ | `--set` | string | — | Required. The set file's name under the package's CDN root, a `.json` file. |
1364
+ | `--name` | string | — | Required. The set's display name. |
1365
+ | `--dry-run` | boolean | `false` | List what would upload and what is skipped; write nothing. |
1366
+
1367
+ **EXIT STATUS**
1368
+
1369
+ 1 when a picture's file has no address, when a picture fails to upload, when
1370
+ `--set` is `default.json` or a path, when `site.assets` is undeclared, and
1371
+ when an upload has no store configured. Warnings about notes do not change
1372
+ it. Otherwise 0.
1373
+
1374
+ **EXAMPLES**
1375
+
1376
+ ```
1377
+ $ package-build cdn publish --set ai-art.json --name "Thalorna — illustrated" --dry-run
1378
+ Dry run: comparing 582 pictures against https://cdn.heroiclands.org, read-only.
1379
+ would upload thalorna/icons/anvilbdg.svg (969 bytes)
1380
+ …
1381
+ would upload thalorna/ai-art.json (582 assets)
1382
+ Would upload 582, skipped 0, failed 0; https://cdn.heroiclands.org/thalorna/ai-art.json lists 582 left-out pictures (would change).
1383
+
1384
+ $ package-build cdn publish --set default.json --name Shipped
1385
+ package-build: `--set default.json` is refused: `default.json` is the shipped set's name, the fallback every other set is read against. Name the set for its art
1386
+ ```
1387
+
1388
+ **SEE ALSO**
1389
+
1390
+ `package-build assets`, `package-build release`,
1391
+ [Configuration](configuration.md#site),
1392
+ [Release and deploy](guides/release-and-deploy.md#publish-pictures-to-the-cdn).
1393
+
1249
1394
  ### `package-build container <stage> <action>`
1250
1395
 
1251
1396
  **NAME**
@@ -1306,7 +1451,9 @@ exact pin takes the major tag of its pin or of `compatibility.minimum`
1306
1451
  mount), `FOUNDRYVTT_CONTAINER_USER` (passed to `docker run --user`; `0:0`
1307
1452
  under rootless Docker makes the data root's files belong to the host user,
1308
1453
  because the image otherwise runs Foundry as user 1000 and ignores
1309
- `FOUNDRY_UID` and `FOUNDRY_GID`).
1454
+ `FOUNDRY_UID` and `FOUNDRY_GID`), `FOUNDRYVTT_CONTAINER_S3` (`1` or `true`
1455
+ runs an S3-compatible server beside the container; see
1456
+ [an S3 server for the container](guides/e2e.md#an-s3-server-for-the-container)).
1310
1457
 
1311
1458
  **EXIT STATUS**
1312
1459
 
@@ -206,6 +206,9 @@ against — the one reader of it is `resolveImg`
206
206
  `packageKind` and `foundryPackage`.
207
207
 
208
208
  - For a `systems` or `modules` package, `<packageKind>/<foundryPackage>/assets`.
209
+ A picture under `icons/` or `images/` is served flat from
210
+ `<packageKind>/<foundryPackage>/<icons|images>/<basename>` instead, the
211
+ layout the stage gives it; every other file keeps its path under `assets/`.
209
212
  - For a `documentation` package, `null` — Foundry serves no files for a
210
213
  package of this kind, and `documentation/null/assets` would be an address
211
214
  that resolves nowhere. `resolveImg` refuses rather than rooting a path
@@ -901,6 +904,12 @@ resolves every relative asset against it:
901
904
 
902
905
  > ``package-build config: `site.assets` is not declared, and a site build needs one — it is the host every package's imagery is served from, and the theme resolves every relative asset against it.``
903
906
 
907
+ `package-build cdn publish` uploads the package's pictures under the same
908
+ host, at `<site.assets>/<contentPackage>/icons/<basename>` and
909
+ `…/images/<basename>`, which is the address the website gives each one. The
910
+ bucket behind the CDN is served at `site.assets`, so an object's key is its
911
+ path below this value.
912
+
904
913
  The generated Hugo configuration carries the same host as
905
914
  `params.cdnBaseURL`, which the theme resolves a relative asset path against.
906
915
  The two are one value read by two readers: the toolchain emits it into a
@@ -1685,7 +1694,7 @@ validator.
1685
1694
  packageBuild:
1686
1695
  assets:
1687
1696
  - { from: lang, to: lang }
1688
- - { from: assets/icons, to: assets/icons }
1697
+ - { from: assets/audio, to: assets/audio }
1689
1698
  assetTransform: ./utils/svg-theme.mjs
1690
1699
  stageDir: build/stage
1691
1700
  clean:
@@ -1701,6 +1710,7 @@ packageBuild:
1701
1710
  | [`packageBuild.stageDir`](#packagebuildstagedir-and-packagebuildassets) | string | no | `build/stage` |
1702
1711
  | [`packageBuild.assets`](#packagebuildstagedir-and-packagebuildassets) | array | no | `[]` |
1703
1712
  | [`packageBuild.assetTransform`](#packagebuildassettransform-and-packagebuildmanifestflags) | string (path to a module) | no | `null` |
1713
+ | [`packageBuild.shippingLicenses`](#packagebuildshippinglicenses) | array of strings | no | `[]` |
1704
1714
  | [`packageBuild.baseStyles`](#packagebuildbasestyles) | boolean | no | `true` |
1705
1715
  | [`packageBuild.manifest`](#packagebuildmanifest) | object, pass-through | no | `{}` |
1706
1716
  | [`packageBuild.manifestFlags`](#packagebuildassettransform-and-packagebuildmanifestflags) | string (path to a module) | no | `null` |
@@ -1715,7 +1725,7 @@ packageBuild:
1715
1725
  | [`packageBuild.proseLint`](#packagebuildproselint) | object | no | `{age: 21, threshold: 5, minWords: 8, rules: readability}` |
1716
1726
  | [`packageBuild.proseScore`](#packagebuildprosescore) | object | no | `{minWords: 80, bands: {}}` |
1717
1727
 
1718
- > ``package-build config: `packageBuild.<key>` is not a recognised key (expected one of: stageDir, assets, assetTransform, baseStyles, manifest, manifestFlags, schema, clean, lang, deploy, release, bundle, container, e2e, proseLint, proseScore).``
1728
+ > ``package-build config: `packageBuild.<key>` is not a recognised key (expected one of: stageDir, assets, assetTransform, shippingLicenses, baseStyles, manifest, manifestFlags, schema, clean, lang, deploy, release, bundle, container, e2e, proseLint, proseScore).``
1719
1729
 
1720
1730
  ### `packageBuild.proseLint`
1721
1731
 
@@ -1765,6 +1775,15 @@ table says `lang`, not `build/stage/lang`.
1765
1775
  `assets` is the table of staging copies: source path in the repository,
1766
1776
  destination under the staged package root.
1767
1777
 
1778
+ **The picture roots are not in it.** `package-build assets` stages
1779
+ `assets/icons/` and `assets/images/` itself — flat at the package root, and
1780
+ only the files whose provenance allows them to ship (see
1781
+ [what a package ships](reference/format-details.md#what-a-package-ships)). A
1782
+ row whose `from` is a picture root, a directory inside one, or a directory
1783
+ holding one is refused at the row:
1784
+
1785
+ > ``package-build.config.yaml:<line>:<column>: error: `packageBuild.assets[<index>]` copies `<from>`, which reaches assets/images/ — the stage copies the icon and image roots itself, flat and filtered by each file's provenance, so a row copying them would ship every file unfiltered. Remove the row``
1786
+
1768
1787
  | Key | Type | Required |
1769
1788
  | ------------------------------- | -------------------------- | -------- |
1770
1789
  | `packageBuild.assets[].from` | string | yes |
@@ -1801,6 +1820,24 @@ repository has to compute. Neither declares anything, both default to
1801
1820
 
1802
1821
  > ``package-build config: `packageBuild.manifestFlags` must be a non-empty string.``
1803
1822
 
1823
+ ### `packageBuild.shippingLicenses`
1824
+
1825
+ Licences this package adds to the default shipping list. A picture ships in the
1826
+ Foundry package when its provenance says `ai: false` and its `license` is on the
1827
+ default list or on this one; the default list is never replaced, and `ai: true`
1828
+ never ships. Each entry is spelled exactly as provenance spells the licence.
1829
+
1830
+ ```yaml
1831
+ packageBuild:
1832
+ shippingLicenses: [CC-BY-NC-SA-4.0]
1833
+ ```
1834
+
1835
+ > ``package-build config: `packageBuild.shippingLicenses` must be a list of licence identifiers.``
1836
+
1837
+ > ``package-build config: `packageBuild.shippingLicenses` entry <index> must be a non-empty string.``
1838
+
1839
+ > ``package-build config: `packageBuild.shippingLicenses` lists `<licence>` more than once.``
1840
+
1804
1841
  ### `packageBuild.baseStyles`
1805
1842
 
1806
1843
  Whether the package takes the shared base stylesheet. It defaults to `true`,
@@ -53,12 +53,36 @@ explains routing for documents and their prose journals.
53
53
 
54
54
  A note can also produce a system document: an Actor, Item, Scene, or Macro according to its type and system blocks. The `sohl:` and `hm3:` blocks supply game-specific mechanics and overrides; they do not create separate prose. The JournalEntry is shared between systems.
55
55
 
56
- An `affiliation` note declares `data.governance.ranks` with at least one rung at
57
- `level: 1`. Each rung states `level`, `title`, and `description`. A being's
58
- `data.affiliations` entry names a rank on that body's ladder; level 1 is the
59
- ordinary standing when no higher rank applies. A single level-1 rung is enough
60
- when the body expresses its hierarchy through `governance.offices` instead.
61
- Missing ranks, an empty list, or a ladder without level 1 are build errors.
56
+ An `affiliation` note declares `data.governance.ranks` with at least two rungs:
57
+ `level: 0`, those the body excludes, and `level: 1`, its ordinary members. Every
58
+ body that can name its members can name those it shuts out, whether or not it
59
+ ever does, so both are required. Each rung states `level`, `title`, and
60
+ `description`, and the level-0 title is the body's own word for the excluded
61
+ rather than a generic "Excluded". A being's `data.affiliations` entry names a
62
+ rank on that body's ladder; level 1 is the ordinary standing when no higher
63
+ rank applies.
64
+
65
+ Add rungs above level 1 only where the body truly has them, generally no more
66
+ than five. A rung is a **standing**, what a member is; an entry in
67
+ `governance.offices` is a **post**, what a member does. Leadership is normally
68
+ an office held by the top rank rather than a rank above it, and a rank never
69
+ falls when an office is left: a bishop stays a bishop after being Presiding
70
+ Bishop.
71
+
72
+ ```yaml
73
+ data:
74
+ governance:
75
+ ranks:
76
+ - { level: 0, title: Apostate, description: Put out of the communion. }
77
+ - { level: 1, title: Layman, description: A baptised member of the faith. }
78
+ - { level: 2, title: Priest, description: Ordained to serve a parish. }
79
+ - { level: 3, title: Bishop, description: Consecrated to oversee a diocese. }
80
+ offices:
81
+ Presiding Bishop: Chairs the synod of bishops for a term.
82
+ ```
83
+
84
+ Missing ranks, an empty list, or a ladder without level 0 or level 1 are build
85
+ errors, reported at the `ranks` key.
62
86
 
63
87
  | Authored section | SoHL Actor | HM3 Actor |
64
88
  | ---------------- | ------------------- | -------------------- |
@@ -159,3 +159,41 @@ major version or moving tag is refused, because the result must name the
159
159
  build actually tested. A passing sweep provides evidence for choosing a
160
160
  new `compatibility.verified` value; a routine run exercises the pinned
161
161
  minimum build.
162
+
163
+ ## An S3 server for the container
164
+
165
+ A suite that exercises S3 storage needs an S3-compatible server the browser and
166
+ Foundry both reach. Setting `FOUNDRYVTT_CONTAINER_S3=1` when a container is
167
+ created (`start`, `recreate`; through `e2e:fast --recreate` as well) provides one.
168
+ Without it nothing changes: no network, no server, no extra environment.
169
+
170
+ With it, before Foundry starts:
171
+
172
+ - A user-defined network named `<container>-net` is created, and the Foundry
173
+ container is created on it.
174
+ - A SeaweedFS server (`chrislusf/seaweedfs:3.80`) named `<container>-s3` joins
175
+ the network under the alias `s3.localhost` and publishes port 8333. Chromium
176
+ resolves every `*.localhost` name to loopback, so `http://s3.localhost:8333`
177
+ reaches the server from the browser and, through the alias, from the
178
+ container. Its CORS allows every origin.
179
+ - The bucket `sohl-e2e` is created. Anonymous read and the test credentials come
180
+ from the identities file the server is started with.
181
+ - The repository's `cypress/s3/aws-e2e.json` is copied into the data root's
182
+ `Config/`, and the container is created with `FOUNDRY_AWS_CONFIG=aws-e2e.json`.
183
+
184
+ The repository supplies both files: `cypress/s3/s3-identities.json` (the
185
+ SeaweedFS identities, including an `anonymous` identity with `Read`) and
186
+ `cypress/s3/aws-e2e.json` (Foundry's `awsConfig`, pointing at
187
+ `http://s3.localhost:8333`). The credentials are test-only. A missing file stops
188
+ the start and names it.
189
+
190
+ ```bash
191
+ FOUNDRYVTT_CONTAINER_S3=1 npm run e2e:fast -- --recreate
192
+ ```
193
+
194
+ The container's environment is fixed when it is created, so an existing
195
+ container started with the variable set joins the network but keeps the
196
+ environment it was created with; `--recreate` applies `FOUNDRY_AWS_CONFIG`.
197
+ `package-build container <stage> rm` with the variable set removes the server
198
+ and the network along with the container. The server is otherwise left running
199
+ between runs, bucket intact.
@@ -68,6 +68,11 @@ defaults point into the system's `assets/images/` and `assets/icons/` trees,
68
68
  introduced by [HM3's asset-path migration](https://git.pupluppy.org/HeroicLands/HarnMaster-3-FoundryVTT/pulls/60).
69
69
  Use these defaults with an HM3 release that includes those assets.
70
70
 
71
+ A compiled document reaches each icon in the deployed layout — the type root
72
+ and the basename, `systems/sohl/icons/person.svg` — whatever directories hold
73
+ it in the repository, and states its address in `flags.<system>.assets`. See
74
+ [what a package ships](../reference/format-details.md#what-a-package-ships).
75
+
71
76
  ## Write an inline icon in prose
72
77
 
73
78
  Declare the glyph vocabulary in `package-build.config.yaml` or a separate YAML
@@ -47,7 +47,10 @@ manifest under `build/dist/`. The ZIP contains the staged tree at its root,
47
47
  which is the shape Foundry installs. When the manifest advertises a content
48
48
  index, the corresponding metadata JSONL is also placed beside the ZIP. A
49
49
  manifest that advertises an index the build has not produced fails the
50
- release. An opted-in staged `schema.json` is published beside it too.
50
+ release. An opted-in staged `schema.json` is published beside it too. A
51
+ package whose stage holds pictures also gets `icons.zip` and `images.zip`:
52
+ each holds one picture root of the stage, flat, with the package's asset
53
+ set `asset-sets/default.json`, so a GM can place the files by hand.
51
54
 
52
55
  When the package publishes content and has a book configuration, the release
53
56
  also builds a PDF beside the archive. Book findings are reported without
@@ -128,6 +131,38 @@ the same stage;
128
131
  [container command](../commands.md#package-build-container-stage-action) for
129
132
  the other actions and environment variables.
130
133
 
134
+ ## Publish pictures to the CDN
135
+
136
+ The website draws every picture a package has from
137
+ `<site.assets>/<contentPackage>/`, and the art a Foundry package leaves out
138
+ reaches a world from there as a named asset set. `package-build cdn publish`
139
+ is the one command that writes there:
140
+
141
+ ```bash
142
+ package-build cdn publish --set ai-art.json --name "Thalorna — illustrated" --dry-run
143
+ package-build cdn publish --set ai-art.json --name "Thalorna — illustrated"
144
+ ```
145
+
146
+ It uploads every icon and image flat, transformed as the stage transforms
147
+ them, skips what the CDN already holds at the same size, and writes
148
+ `<site.assets>/<contentPackage>/ai-art.json` listing the left-out pictures.
149
+ Run it after the pictures or their provenance change; it is not part of a
150
+ build or a release.
151
+
152
+ The bucket's credentials go in `.env.local` beside the stage paths:
153
+
154
+ ```dotenv
155
+ CDN_ENDPOINT=https://<account id>.r2.cloudflarestorage.com
156
+ CDN_BUCKET=<bucket>
157
+ CDN_ACCESS_KEY_ID=<access key id>
158
+ CDN_SECRET_ACCESS_KEY=<secret access key>
159
+ ```
160
+
161
+ A dry run with none of them set compares against the public CDN instead. A
162
+ dispatched workflow passes the same four values as named secrets. See the
163
+ [command reference](../commands.md#package-build-cdn-publish) for the set
164
+ file's shape, the refusals and the exit status.
165
+
131
166
  ## Publish a package-build npm version
132
167
 
133
168
  For this toolchain's own npm release, a pull request with a change installers