@heroiclands/package-build 23.4.1 → 23.5.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +29 -25
  3. package/assets/images/provenance.yaml +1 -0
  4. package/bin/package-build.mjs +259 -101
  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 -51
  12. package/docs/configuration.md +39 -2
  13. package/docs/content-format.md +30 -6
  14. package/docs/diagnostics.md +1 -2
  15. package/docs/guides/e2e.md +38 -0
  16. package/docs/guides/icons.md +5 -0
  17. package/docs/guides/module-project.md +1 -1
  18. package/docs/guides/release-and-deploy.md +36 -1
  19. package/docs/project-setup.md +16 -59
  20. package/docs/reference/format-details.md +180 -34
  21. package/docs/reference/note-types.md +69 -69
  22. package/engine/asset-index.mjs +63 -22
  23. package/engine/asset-references.mjs +355 -0
  24. package/engine/asset-shipping.mjs +363 -0
  25. package/engine/asset-types.mjs +70 -0
  26. package/engine/base-compiler.mjs +24 -1
  27. package/engine/cdn-publish.mjs +643 -0
  28. package/engine/index.mjs +6 -0
  29. package/engine/note-vocabulary.mjs +4 -1
  30. package/engine/pathnames.mjs +21 -10
  31. package/engine/project-init.mjs +4 -29
  32. package/engine/rank-ladder.mjs +40 -16
  33. package/engine/scenes.mjs +1 -1
  34. package/engine/standing-terms.mjs +3 -1
  35. package/package.json +5 -6
  36. package/packagebuild-metadata.jsonl +13 -13
  37. package/release.mjs +69 -7
  38. package/sohl/actors.mjs +12 -4
  39. package/sohl/default-item-art.mjs +23 -19
  40. package/types/config.d.mts +7 -0
  41. package/types/container.d.mts +137 -1
  42. package/types/engine/asset-index.d.mts +3 -2
  43. package/types/engine/asset-references.d.mts +136 -0
  44. package/types/engine/asset-shipping.d.mts +240 -0
  45. package/types/engine/asset-types.d.mts +36 -0
  46. package/types/engine/base-compiler.d.mts +7 -1
  47. package/types/engine/cdn-publish.d.mts +312 -0
  48. package/types/engine/index.d.mts +2 -0
  49. package/types/engine/pathnames.d.mts +2 -1
  50. package/types/engine/rank-ladder.d.mts +25 -6
  51. package/types/release.d.mts +5 -3
  52. package/types/sohl/default-item-art.d.mts +2 -1
  53. package/labels.mjs +0 -191
  54. package/types/labels.d.mts +0 -40
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ `cdn publish` skips SVGs the CDN already holds, so a re-run uploads only what changed.
8
+
9
+ `package-build labels check` is gone, because issue labels are defined once for the whole organisation; remove any `lint:labels` script and `.github/labels.yml`. A project created by `package-build init` no longer gets a label registry.
10
+
11
+ ## 23.5.0
12
+
13
+ ### Minor Changes
14
+
15
+ **Affiliations**
16
+
17
+ - `package-build lint` requires every affiliation's ladder to have a level-0 rung for those it excludes, beside its level-1 members.
18
+
19
+ **Provenance**
20
+
21
+ - Every icon and image must be covered by a provenance record, and every record must state `ai: true` or `ai: false`; a package missing either now fails its build until it adds them.
22
+
23
+ **What a Foundry package ships**
24
+
25
+ - A package ships only the icons and images whose provenance says `ai: false` under a public-domain, CC0, CC-BY, CC-BY-SA or Noun Project Pro licence; everything else stays in the repository.
26
+ - Shipped pictures sit flat at the package root as `icons/<file>` and `images/<file>`, and compendium documents and the website point there; remove any `packageBuild.assets` row copying `assets/icons` or `assets/images`, which is now refused.
27
+ - `asset-sets/default.json` in the package lists the shipped pictures, and each release attaches `icons.zip` and `images.zip`.
28
+ - A picture the package leaves out shows the package's `missing` stand-in, and every picture reference in a compiled document records which asset it is.
29
+ - Items created without art get their default icon again, at its flat `icons/` location.
30
+ - A package can ship pictures under further licences by listing them in `packageBuild.shippingLicenses`; the default list still applies and `ai: true` pictures never ship.
31
+
32
+ ### Patch Changes
33
+
34
+ - New `package-build cdn publish --set <file> --name <name>` uploads every icon and image a package has to its CDN folder, flat, and publishes the art the package leaves out as a named asset set a world can add.
35
+ - A second run uploads nothing, and `--dry-run` lists what would upload; the bucket is read from `CDN_ENDPOINT`, `CDN_BUCKET`, `CDN_ACCESS_KEY_ID` and `CDN_SECRET_ACCESS_KEY`.
36
+ - The end-to-end container can run an S3-compatible server beside Foundry: set `FOUNDRYVTT_CONTAINER_S3=1` and a suite can exercise S3 storage.
37
+
3
38
  ## 23.4.1
4
39
 
5
40
  ### Patch Changes
package/README.md CHANGED
@@ -20,34 +20,38 @@ The generated project includes a homepage, website and book targets, npm scripts
20
20
 
21
21
  The documentation lives in the Markdown files in this repository. Start with [the documentation home](docs/index.md). This toolchain does not build its own site or book. A Hugo theme dependency would form a cycle because the theme uses package-build for releases.
22
22
 
23
- | Topic | Guide |
24
- | ------------------------------------------------- | ------------------------------------------------------------ |
25
- | Documentation landing | [Home](docs/index.md) |
26
- | Project layout and scripts | [Project setup](docs/project-setup.md) |
27
- | Note model and output | [Content format](docs/content-format.md) |
28
- | Frontmatter and system blocks | [Frontmatter](docs/authoring/frontmatter.md) |
29
- | Addresses, links, anchors, SQL, and secret blocks | [Links and markup](docs/authoring/links-and-markup.md) |
30
- | Images, icons, audio, and provenance | [Assets](docs/authoring/assets.md) |
31
- | Dates, calendars, and eras | [Dates and calendars](docs/authoring/dates-and-calendars.md) |
32
- | Complete note fields and mappings | [Note types](docs/reference/note-types.md) |
33
- | Detailed format rules and examples | [Format details](docs/reference/format-details.md) |
34
- | Compendium routing and document builds | [Packs](docs/guides/packs.md) |
35
- | Website build and publication | [Site](docs/guides/site.md) |
36
- | What the theme draws on a page | [Theme pages](docs/reference/theme-pages.md) |
37
- | PDF book build | [Book](docs/guides/book.md) |
38
- | Icon generation and use | [Icons](docs/guides/icons.md) |
39
- | Reference pages and content indexes | [Reference pages](docs/guides/reference-pages.md) |
40
- | Localization and templates | [Localization](docs/guides/localization.md) |
41
- | Foundry integration testing | [End-to-end testing](docs/guides/e2e.md) |
42
- | Release and deployment | [Release and deploy](docs/guides/release-and-deploy.md) |
43
- | Every CLI command | [Commands](docs/commands.md) |
44
- | Every build setting | [Configuration](docs/configuration.md) |
45
- | Programmatic exports | [API](docs/api.md) |
46
- | Error locations and severities | [Diagnostics](docs/diagnostics.md) |
47
- | Architectural rationale | [Design](docs/design.md) |
23
+ | Topic | Guide |
24
+ | ------------------------------------------------- | ------------------------------------------------------------------------- |
25
+ | Documentation landing | [Home](docs/index.md) |
26
+ | Project layout and scripts | [Project setup](docs/project-setup.md) |
27
+ | Note model and output | [Content format](docs/content-format.md) |
28
+ | Frontmatter and system blocks | [Frontmatter](docs/authoring/frontmatter.md) |
29
+ | Addresses, links, anchors, SQL, and secret blocks | [Links and markup](docs/authoring/links-and-markup.md) |
30
+ | Images, icons, audio, and provenance | [Assets](docs/authoring/assets.md) |
31
+ | Dates, calendars, and eras | [Dates and calendars](docs/authoring/dates-and-calendars.md) |
32
+ | Complete note fields and mappings | [Note types](docs/reference/note-types.md) |
33
+ | Detailed format rules and examples | [Format details](docs/reference/format-details.md) |
34
+ | Compendium routing and document builds | [Packs](docs/guides/packs.md) |
35
+ | Website build and publication | [Site](docs/guides/site.md) |
36
+ | What the theme draws on a page | [Theme pages](docs/reference/theme-pages.md) |
37
+ | PDF book build | [Book](docs/guides/book.md) |
38
+ | Icon generation and use | [Icons](docs/guides/icons.md) |
39
+ | Reference pages and content indexes | [Reference pages](docs/guides/reference-pages.md) |
40
+ | Localization and templates | [Localization](docs/guides/localization.md) |
41
+ | Foundry integration testing | [End-to-end testing](docs/guides/e2e.md), including an optional S3 server |
42
+ | Release and deployment | [Release and deploy](docs/guides/release-and-deploy.md) |
43
+ | Every CLI command | [Commands](docs/commands.md) |
44
+ | Every build setting | [Configuration](docs/configuration.md) |
45
+ | Programmatic exports | [API](docs/api.md) |
46
+ | Error locations and severities | [Diagnostics](docs/diagnostics.md) |
47
+ | Architectural rationale | [Design](docs/design.md) |
48
48
 
49
49
  Place notes may declare a governing affiliation with `data.government` (`Address`, default target type `affiliation`), or explicit `null` for complete anarchy. Positive population with an omitted government produces an advisory. See [place governments](docs/authoring/frontmatter.md#place-governments).
50
50
 
51
+ Every icon and image is covered by a provenance record stating `attribution`, `license` and `ai`. A Foundry package ships only the pictures whose record says `ai: false` under a licence on the shipping list, staged flat at the package root as `icons/<basename>` and `images/<basename>` and listed in its asset set, `asset-sets/default.json`; a reference to a picture it leaves out draws the package's `missing` stand-in, and every compiled reference states its asset's address. See [assets](docs/authoring/assets.md#what-the-foundry-package-ships) and [what a package ships](docs/reference/format-details.md#what-a-package-ships).
52
+
53
+ `package-build cdn publish --set <file> --name <display name>` uploads every icon and image the package has, shipped or not, to `<site.assets>/<contentPackage>/` in that flat layout, and writes the named set listing the pictures the package leaves out. It reads the bucket from `CDN_ENDPOINT`, `CDN_BUCKET`, `CDN_ACCESS_KEY_ID` and `CDN_SECRET_ACCESS_KEY`, in the environment or `.env.local`. See [publish pictures to the CDN](docs/guides/release-and-deploy.md#publish-pictures-to-the-cdn).
54
+
51
55
  Leading captions, generic fenced divs, inline spans, and poetry code fences are described in [links and markup](docs/authoring/links-and-markup.md).
52
56
 
53
57
  The package is GPL-3.0-or-later for code and CC-BY-SA-4.0 for original content. See [LICENSE.md](LICENSE.md).
@@ -1,4 +1,5 @@
1
1
  attribution: Tom Rodriguez
2
2
  source: ""
3
3
  license: CC-BY-SA-4.0
4
+ ai: true
4
5
  notes: ""
@@ -61,6 +61,7 @@
61
61
  * npx package-build bundle check
62
62
  * npx package-build release
63
63
  * npx package-build deploy <stage>
64
+ * npx package-build cdn publish --set <file> --name <display name> [--dry-run]
64
65
  * npx package-build container <stage> <action>
65
66
  * npx package-build e2e <seed|run|open|fast|sweep>
66
67
  *
@@ -87,13 +88,25 @@ import { hideBin } from "yargs/helpers";
87
88
 
88
89
  import { loadPackageBuildConfig } from "../config.mjs";
89
90
  import { checkForeignAssetBindings } from "../engine/asset-bindings.mjs";
90
- import { emitDiagnostic, formatDiagnostic } from "../engine/diagnostics.mjs";
91
+ import { emitDiagnostic, formatDiagnostic, positionOfYamlPath } from "../engine/diagnostics.mjs";
91
92
  import { writeSiteRoot } from "../engine/site-root.mjs";
92
93
  import { DEPLOY_ROOT } from "../engine/site-config.mjs";
93
94
  import { compilesFoundryDocuments } from "../content-config.mjs";
94
95
  import { loadPackConfig, packConfigPath, resolveConfigFile } from "../engine/pack-config.mjs";
95
96
  import { cleanBuildArtifacts, resetStage, stageAssets } from "../stage.mjs";
97
+ import { rowsStagingPictureRoots, stagePackageAssets } from "../engine/asset-shipping.mjs";
96
98
  import { baseStyleStageEntry } from "../engine/base-styles.mjs";
99
+ import {
100
+ CDN_ENV,
101
+ cdnCredentials,
102
+ noteArtFindings,
103
+ noteArtReferences,
104
+ planCdnPublish,
105
+ publicStore,
106
+ publishToCdn,
107
+ s3Store,
108
+ setFileProblem,
109
+ } from "../engine/cdn-publish.mjs";
97
110
  import { buildSchemaArtifact } from "../engine/schema-extract.mjs";
98
111
  import { emitCalendarArtifacts } from "../engine/calendar-artifacts.mjs";
99
112
  import { dateFromCalendar, dateToCalendar } from "../engine/date-conversion.mjs";
@@ -101,7 +114,6 @@ import { walkMarkdownTree } from "../engine/helpers.mjs";
101
114
  import { reckoningContext } from "../engine/reckoning-markers.mjs";
102
115
  import { SCHEMA_ARTIFACT_FILE } from "../engine/foreign-catalog.mjs";
103
116
  import { validateLangSource } from "../lang.mjs";
104
- import { checkLabelRegistry } from "../labels.mjs";
105
117
  import { lintYaml } from "../engine/yaml-lint.mjs";
106
118
  import { lintChangesetText, lintReleaseText } from "../engine/changelog-lint.mjs";
107
119
  import { groupChangelogText } from "../engine/changelog-group.mjs";
@@ -421,6 +433,62 @@ function cleanCommand() {
421
433
  };
422
434
  }
423
435
 
436
+ /**
437
+ * A finding for each `packageBuild.assets` row that would stage a picture root
438
+ * itself, located at the row's `from` when the configuration is YAML.
439
+ *
440
+ * @param {readonly {from: string, to: string}[]} rows - The configured rows.
441
+ * @param {{rootDir: string, assetsBase: string}} opts - Where they resolve.
442
+ * @returns {object[]} The findings.
443
+ */
444
+ function pictureRootRowFindings(rows, { rootDir, assetsBase }) {
445
+ const offending = rowsStagingPictureRoots(rows, { rootDir, assetsBase });
446
+ if (!offending.length) return [];
447
+ const file = packConfigPath();
448
+ const text =
449
+ file.endsWith(".yaml") || file.endsWith(".yml") ? fs.readFileSync(file, "utf8") : "";
450
+ return offending.map(({ index, from, root }) => ({
451
+ file,
452
+ ...(text ? positionOfYamlPath(text, ["packageBuild", "assets", index, "from"]) : {}),
453
+ severity: "error",
454
+ message:
455
+ `\`packageBuild.assets[${index}]\` copies \`${from}\`, which reaches ${root}/ — ` +
456
+ "the stage copies the icon and image roots itself, flat and filtered by " +
457
+ "each file's provenance, so a row copying them would ship every file " +
458
+ "unfiltered. Remove the row",
459
+ }));
460
+ }
461
+
462
+ /**
463
+ * The repository's `packageBuild.assetTransform`, loaded.
464
+ *
465
+ * One loader for every command that writes a picture, so the stage and the CDN
466
+ * hold the same bytes for the same file.
467
+ *
468
+ * @param {object} config - The resolved package-build configuration.
469
+ * @returns {Promise<((sourcePath: string) => string|null|undefined)|undefined>}
470
+ * The transform, or `undefined` when the repository names none.
471
+ */
472
+ async function loadAssetTransform(config) {
473
+ if (!config.assetTransform) return undefined;
474
+ const module = await import(`file://${config.assetTransform}`).catch((err) =>
475
+ die(
476
+ `cannot load \`packageBuild.assetTransform\` ` +
477
+ `(${config.assetTransform}): ${err.message}`,
478
+ ),
479
+ );
480
+ const transform = module.transform;
481
+ if (typeof transform !== "function") {
482
+ die(
483
+ `\`packageBuild.assetTransform\` ` +
484
+ `(${config.assetTransform}) exports no \`transform\` ` +
485
+ `function. It must export ` +
486
+ `\`transform(sourcePath) -> string | null\`.`,
487
+ );
488
+ }
489
+ return transform;
490
+ }
491
+
424
492
  /**
425
493
  * `assets` — stage the repository's static files into the package root.
426
494
  *
@@ -440,37 +508,22 @@ function assetsCommand() {
440
508
  builder: (y) => y,
441
509
  handler: handler(async () => {
442
510
  const config = loadPackageBuildConfig();
443
- if (!config.assets.length && !config.baseStyles) {
444
- console.log("package-build: no `packageBuild.assets` declared; nothing to stage.");
445
- return;
446
- }
447
- const bindings = checkForeignAssetBindings(loadPackConfig(), {
448
- assets: config.assets,
449
- });
450
- if (bindings.length) {
451
- const error = new Error(bindings.map(formatDiagnostic).join("\n"));
511
+ const packConfig = loadPackConfig();
512
+ const assetsBase = packConfig.paths.assets;
513
+ const findings = [
514
+ ...pictureRootRowFindings(config.assets, {
515
+ rootDir: config.rootDir,
516
+ assetsBase,
517
+ }),
518
+ ...checkForeignAssetBindings(packConfig, { assets: config.assets }),
519
+ ];
520
+ if (findings.length) {
521
+ const error = new Error(findings.map(formatDiagnostic).join("\n"));
452
522
  error.located = true;
453
523
  die(error);
454
524
  }
455
525
 
456
- let transform;
457
- if (config.assetTransform) {
458
- const module = await import(`file://${config.assetTransform}`).catch((err) =>
459
- die(
460
- `cannot load \`packageBuild.assetTransform\` ` +
461
- `(${config.assetTransform}): ${err.message}`,
462
- ),
463
- );
464
- transform = module.transform;
465
- if (typeof transform !== "function") {
466
- die(
467
- `\`packageBuild.assetTransform\` ` +
468
- `(${config.assetTransform}) exports no \`transform\` ` +
469
- `function. It must export ` +
470
- `\`transform(sourcePath) -> string | null\`.`,
471
- );
472
- }
473
- }
526
+ const transform = await loadAssetTransform(config);
474
527
 
475
528
  // `to:` is relative to the staged package root, so a
476
529
  // repository's table reads `lang`, not `build/stage/lang`.
@@ -483,6 +536,35 @@ function assetsCommand() {
483
536
  transform,
484
537
  });
485
538
 
539
+ // The picture roots, staged by the toolchain rather than by a row:
540
+ // flat, filtered by each file's provenance, and listed in the
541
+ // package's asset set.
542
+ const pictures = stagePackageAssets({
543
+ assetsBase,
544
+ contentPackage: packConfig.contentPackage,
545
+ stageDir: path.resolve(config.rootDir, config.stageDir),
546
+ // The manifest's title names the set; a package with none is
547
+ // named by its content package.
548
+ title:
549
+ typeof config.manifest?.title === "string" && config.manifest.title.trim() ?
550
+ config.manifest.title
551
+ : packConfig.contentPackage,
552
+ transform,
553
+ shippingLicenses: config.shippingLicenses,
554
+ });
555
+ if (pictures.findings.length) {
556
+ const error = new Error(pictures.findings.map(formatDiagnostic).join("\n"));
557
+ error.located = true;
558
+ die(error);
559
+ }
560
+ files += pictures.staged.length;
561
+ if (pictures.staged.length || pictures.excluded.length) {
562
+ console.log(
563
+ `Pictures: ${pictures.staged.length} staged, ` +
564
+ `${pictures.excluded.length} left out by provenance.`,
565
+ );
566
+ }
567
+
486
568
  // Staged in its own pass, without the repository's transform: the
487
569
  // sheet is this toolchain's file rather than one of the
488
570
  // repository's assets, and a `transform` written to retheme a
@@ -1245,77 +1327,6 @@ function changelogGroup(args) {
1245
1327
  console.log(`package-build: ${files.length} file(s) grouped · ${warnings} warning(s)`);
1246
1328
  }
1247
1329
 
1248
- /**
1249
- * `labels check` — do the machine registry and the documented table agree?
1250
- *
1251
- * `.github/labels.yml` is synced to GitHub and the §3 table in
1252
- * `.github/ISSUE_REPORTING.md` is what a person reads. Neither derives from the
1253
- * other, so either can drift, and nothing notices until an issue is filed
1254
- * against a label that does not exist.
1255
- *
1256
- * Every repository wants this and only the paths ever differed, which is why it
1257
- * is here rather than copied into each as a `utils/` script.
1258
- *
1259
- * @returns {object} The yargs command module.
1260
- */
1261
- function labelsCommand() {
1262
- return {
1263
- command: "labels <action>",
1264
- describe: "Issue-label registry checks",
1265
- builder: (y) =>
1266
- y
1267
- .positional("action", {
1268
- choices: ["check"],
1269
- describe: "check: the registry and the documented table name the same labels",
1270
- })
1271
- .option("registry", {
1272
- type: "string",
1273
- default: ".github/labels.yml",
1274
- describe: "The machine registry synced to GitHub",
1275
- })
1276
- .option("doc", {
1277
- type: "string",
1278
- default: ".github/ISSUE_REPORTING.md",
1279
- describe: "The documented reference whose §3 table lists the same labels",
1280
- }),
1281
- handler: handler(async (args) => labelsCheck(args)),
1282
- };
1283
- }
1284
-
1285
- /**
1286
- * Read both faces of the registry and report where they disagree.
1287
- *
1288
- * Findings are reported against the file each belongs to, so a missing row is
1289
- * located in the documentation and a missing entry in the registry, rather than
1290
- * both being attributed to whichever file was read first.
1291
- *
1292
- * @param {object} args - Parsed CLI arguments.
1293
- */
1294
- function labelsCheck(args) {
1295
- for (const target of [args.registry, args.doc]) {
1296
- if (!fs.existsSync(target)) die(`labels check: ${target} does not exist.`);
1297
- }
1298
- const registryText = fs.readFileSync(args.registry, "utf8");
1299
- const docText = fs.readFileSync(args.doc, "utf8");
1300
-
1301
- const { registry, doc, count } = checkLabelRegistry({
1302
- registryText,
1303
- docText,
1304
- docPath: args.doc,
1305
- });
1306
-
1307
- const errors =
1308
- reportFindings(registry, { file: args.registry }) + reportFindings(doc, { file: args.doc });
1309
- if (errors) {
1310
- console.error(
1311
- `\nThe registry and ${args.doc} §3 are edited together — ` +
1312
- `change one and change the other.`,
1313
- );
1314
- process.exit(1);
1315
- }
1316
- console.log(`package-build: registry and §3 agree (${count} labels).`);
1317
- }
1318
-
1319
1330
  /**
1320
1331
  * `lang <action>` — the three localization guards.
1321
1332
  *
@@ -1464,6 +1475,9 @@ function releaseCommand() {
1464
1475
  }),
1465
1476
  );
1466
1477
  }
1478
+ for (const archive of result.pictures ?? []) {
1479
+ console.log(`🖼 Pictures: ${path.relative(config.rootDir, archive)}`);
1480
+ }
1467
1481
  for (const pdf of result.pdfs ?? []) {
1468
1482
  console.log(`📕 Book: ${path.relative(config.rootDir, pdf)}`);
1469
1483
  }
@@ -1534,6 +1548,150 @@ function deployCommand() {
1534
1548
  };
1535
1549
  }
1536
1550
 
1551
+ /**
1552
+ * `cdn publish` — upload every picture the package has to the CDN, and the art
1553
+ * the package leaves out as a named asset set.
1554
+ *
1555
+ * The one writer to `<site.assets>/<package>/`. It is run by hand or by a
1556
+ * dispatched workflow, never by a build or a release. The store is named by
1557
+ * the `CDN_*` variables in `CDN_ENV`, read from the environment or
1558
+ * `.env.local`; a dry run with none of them set compares against the public
1559
+ * CDN instead.
1560
+ *
1561
+ * @returns {object} The yargs command module.
1562
+ */
1563
+ function cdnPublishCommand() {
1564
+ return {
1565
+ command: "publish",
1566
+ describe: "Upload every picture to the CDN and write a named asset set",
1567
+ builder: (y) =>
1568
+ y
1569
+ .option("set", {
1570
+ type: "string",
1571
+ demandOption: true,
1572
+ describe: "The set file's name under the package's CDN root, e.g. alt-art.json",
1573
+ })
1574
+ .option("name", {
1575
+ type: "string",
1576
+ demandOption: true,
1577
+ describe: "The set's display name",
1578
+ })
1579
+ .option("dry-run", {
1580
+ type: "boolean",
1581
+ default: false,
1582
+ describe: "List what would upload and what is skipped; write nothing",
1583
+ }),
1584
+ handler: handler(async (args) => {
1585
+ const config = loadPackageBuildConfig();
1586
+ const packConfig = loadPackConfig();
1587
+ await loadEnvironment(config);
1588
+
1589
+ const setProblem = setFileProblem(args.set);
1590
+ if (setProblem) die(setProblem);
1591
+ const name = String(args.name ?? "").trim();
1592
+ if (!name) die("`--name` is blank; a set needs a display name");
1593
+ const host = String(packConfig.site?.assets ?? "").trim();
1594
+ if (!host) {
1595
+ die(
1596
+ "`site.assets` is not declared, and it is the CDN root every picture " +
1597
+ "is published under",
1598
+ );
1599
+ }
1600
+ const credentials = cdnCredentials(process.env);
1601
+ if (!credentials && !args.dryRun) {
1602
+ die(
1603
+ `no CDN store is configured: set ${Object.values(CDN_ENV).join(", ")} ` +
1604
+ "in the environment or `.env.local`",
1605
+ );
1606
+ }
1607
+
1608
+ const plan = planCdnPublish({
1609
+ assetsBase: packConfig.paths.assets,
1610
+ contentPackage: packConfig.contentPackage,
1611
+ host,
1612
+ setFile: args.set,
1613
+ setName: name,
1614
+ transform: await loadAssetTransform(config),
1615
+ shippingLicenses: config.shippingLicenses,
1616
+ });
1617
+ if (plan.findings.length) {
1618
+ const located = plan.findings.map((finding) => ({
1619
+ ...finding,
1620
+ file:
1621
+ path.isAbsolute(finding.file) ?
1622
+ path.relative(process.cwd(), finding.file)
1623
+ : finding.file,
1624
+ }));
1625
+ reportFindings(located, {});
1626
+ process.exitCode = 1;
1627
+ return;
1628
+ }
1629
+ const excluded = new Set(
1630
+ plan.uploads.filter((upload) => !upload.shipped).map((upload) => upload.record),
1631
+ );
1632
+ const references = noteArtReferences({
1633
+ contentBase: packConfig.paths.content,
1634
+ skipDirectories: packConfig.skipDirectories,
1635
+ contentPackage: packConfig.contentPackage,
1636
+ records: plan.records,
1637
+ });
1638
+
1639
+ const store = credentials ? s3Store(credentials) : publicStore(host);
1640
+ console.log(
1641
+ `${args.dryRun ? "Dry run: comparing" : "Publishing"} ${plan.uploads.length} ` +
1642
+ `pictures against ${store.describe}.`,
1643
+ );
1644
+ const result = await publishToCdn(plan, {
1645
+ store,
1646
+ dryRun: args.dryRun,
1647
+ log: (line) => console.log(` ${line}`),
1648
+ });
1649
+ const failures = result.failed.map(({ upload, message }) => ({
1650
+ file: path.relative(
1651
+ process.cwd(),
1652
+ path.join(packConfig.paths.assets, ...upload.record.asset.path.split("/")),
1653
+ ),
1654
+ severity: "error",
1655
+ message: `${upload.key} did not upload: ${message}`,
1656
+ }));
1657
+ const failedCount = reportFindings(
1658
+ [
1659
+ ...failures,
1660
+ ...noteArtFindings(references, {
1661
+ contentPackage: packConfig.contentPackage,
1662
+ published: result.published,
1663
+ excluded,
1664
+ }),
1665
+ ],
1666
+ {},
1667
+ );
1668
+ if (failedCount > 0) process.exitCode = 1;
1669
+ const set = /** @type {NonNullable<typeof plan.set>} */ (plan.set);
1670
+ const setState =
1671
+ result.set === "skipped" ? "unchanged"
1672
+ : result.set === "withheld" ? "withheld"
1673
+ : args.dryRun ? "would change"
1674
+ : "written";
1675
+ console.log(
1676
+ `${args.dryRun ? "Would upload" : "Uploaded"} ${result.uploaded.length}, ` +
1677
+ `skipped ${result.skipped.length}, failed ${result.failed.length}; ` +
1678
+ `${plan.cdnRoot}${args.set} lists ` +
1679
+ `${set.set.assets.length} left-out pictures (${setState}).`,
1680
+ );
1681
+ }),
1682
+ };
1683
+ }
1684
+
1685
+ /** Publish the package's pictures to the CDN. */
1686
+ function cdnCommand() {
1687
+ return {
1688
+ command: "cdn <action>",
1689
+ describe: "Publish the package's pictures to the CDN",
1690
+ builder: (yargs) => yargs.command(cdnPublishCommand()).demandCommand(1).strict(),
1691
+ handler: () => {},
1692
+ };
1693
+ }
1694
+
1537
1695
  /**
1538
1696
  * `container <stage> <action>` — run a stage's Foundry in a container.
1539
1697
  *
@@ -1742,13 +1900,13 @@ registerContentCommands(
1742
1900
  .command(typesCommand())
1743
1901
  .command(schemaCommand())
1744
1902
  .command(langCommand())
1745
- .command(labelsCommand())
1746
1903
  .command(bumpCommand())
1747
1904
  .command(yamlCommand())
1748
1905
  .command(changelogCommand())
1749
1906
  .command(bundleCommand())
1750
1907
  .command(releaseCommand())
1751
1908
  .command(deployCommand())
1909
+ .command(cdnCommand())
1752
1910
  .command(containerCommand())
1753
1911
  .command(e2eCommand()),
1754
1912
  )
@@ -0,0 +1,73 @@
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
+ * Runs this repository's Prettier over the paths given, or over the whole tree
16
+ * when none are.
17
+ *
18
+ * `npm run format -- a.mjs b.md` appends `a.mjs b.md` to the script's command
19
+ * line, so a script of `prettier --write .` formats everything regardless.
20
+ * This wrapper takes the mode first and treats whatever follows as the paths.
21
+ * A path that no longer exists, such as a file a branch deleted, is skipped
22
+ * rather than failing the run. Prettier still resolves the repository's own
23
+ * configuration and ignore file.
24
+ *
25
+ * ```text
26
+ * node ci/prettier.mjs --write [paths...]
27
+ * node ci/prettier.mjs --check [paths...]
28
+ * ```
29
+ *
30
+ * @module
31
+ */
32
+
33
+ import fs from "node:fs";
34
+ import { createRequire } from "node:module";
35
+ import { spawnSync } from "node:child_process";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ /**
39
+ * The Prettier arguments for one invocation.
40
+ *
41
+ * @param {readonly string[]} args - The mode, `--write` or `--check`, then the
42
+ * paths.
43
+ * @param {(path: string) => boolean} [exists] - Whether a path exists.
44
+ * @returns {string[]|null} The arguments, `.` when no path was given, or `null`
45
+ * when paths were given and none exists, so there is nothing to run.
46
+ * @throws {Error} When the mode is missing or unknown.
47
+ */
48
+ export function prettierArgs(args, exists = fs.existsSync) {
49
+ const [mode, ...paths] = args;
50
+ if (mode !== "--write" && mode !== "--check") {
51
+ throw new Error("the first argument must be --write or --check");
52
+ }
53
+ if (paths.length === 0) return [mode, "."];
54
+ const present = paths.filter((entry) => exists(entry));
55
+ return present.length ? [mode, ...present] : null;
56
+ }
57
+
58
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
59
+ let args;
60
+ try {
61
+ args = prettierArgs(process.argv.slice(2));
62
+ } catch (error) {
63
+ console.error(error.message);
64
+ process.exit(2);
65
+ }
66
+ if (args === null) {
67
+ console.log("None of the given paths exist; nothing to format.");
68
+ } else {
69
+ const bin = createRequire(import.meta.url).resolve("prettier/bin/prettier.cjs");
70
+ const result = spawnSync(process.execPath, [bin, ...args], { stdio: "inherit" });
71
+ process.exit(result.status ?? 1);
72
+ }
73
+ }