@heroiclands/package-build 23.4.1 → 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 +27 -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 +180 -34
  18. package/docs/reference/note-types.md +69 -69
  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 +4 -1
  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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ **Affiliations**
8
+
9
+ - `package-build lint` requires every affiliation's ladder to have a level-0 rung for those it excludes, beside its level-1 members.
10
+
11
+ **Provenance**
12
+
13
+ - 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.
14
+
15
+ **What a Foundry package ships**
16
+
17
+ - 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.
18
+ - 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.
19
+ - `asset-sets/default.json` in the package lists the shipped pictures, and each release attaches `icons.zip` and `images.zip`.
20
+ - 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.
21
+ - Items created without art get their default icon again, at its flat `icons/` location.
22
+ - 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.
23
+
24
+ ### Patch Changes
25
+
26
+ - 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.
27
+ - 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`.
28
+ - 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.
29
+
3
30
  ## 23.4.1
4
31
 
5
32
  ### 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";
@@ -421,6 +434,62 @@ function cleanCommand() {
421
434
  };
422
435
  }
423
436
 
437
+ /**
438
+ * A finding for each `packageBuild.assets` row that would stage a picture root
439
+ * itself, located at the row's `from` when the configuration is YAML.
440
+ *
441
+ * @param {readonly {from: string, to: string}[]} rows - The configured rows.
442
+ * @param {{rootDir: string, assetsBase: string}} opts - Where they resolve.
443
+ * @returns {object[]} The findings.
444
+ */
445
+ function pictureRootRowFindings(rows, { rootDir, assetsBase }) {
446
+ const offending = rowsStagingPictureRoots(rows, { rootDir, assetsBase });
447
+ if (!offending.length) return [];
448
+ const file = packConfigPath();
449
+ const text =
450
+ file.endsWith(".yaml") || file.endsWith(".yml") ? fs.readFileSync(file, "utf8") : "";
451
+ return offending.map(({ index, from, root }) => ({
452
+ file,
453
+ ...(text ? positionOfYamlPath(text, ["packageBuild", "assets", index, "from"]) : {}),
454
+ severity: "error",
455
+ message:
456
+ `\`packageBuild.assets[${index}]\` copies \`${from}\`, which reaches ${root}/ — ` +
457
+ "the stage copies the icon and image roots itself, flat and filtered by " +
458
+ "each file's provenance, so a row copying them would ship every file " +
459
+ "unfiltered. Remove the row",
460
+ }));
461
+ }
462
+
463
+ /**
464
+ * The repository's `packageBuild.assetTransform`, loaded.
465
+ *
466
+ * One loader for every command that writes a picture, so the stage and the CDN
467
+ * hold the same bytes for the same file.
468
+ *
469
+ * @param {object} config - The resolved package-build configuration.
470
+ * @returns {Promise<((sourcePath: string) => string|null|undefined)|undefined>}
471
+ * The transform, or `undefined` when the repository names none.
472
+ */
473
+ async function loadAssetTransform(config) {
474
+ if (!config.assetTransform) return undefined;
475
+ const module = await import(`file://${config.assetTransform}`).catch((err) =>
476
+ die(
477
+ `cannot load \`packageBuild.assetTransform\` ` +
478
+ `(${config.assetTransform}): ${err.message}`,
479
+ ),
480
+ );
481
+ const transform = module.transform;
482
+ if (typeof transform !== "function") {
483
+ die(
484
+ `\`packageBuild.assetTransform\` ` +
485
+ `(${config.assetTransform}) exports no \`transform\` ` +
486
+ `function. It must export ` +
487
+ `\`transform(sourcePath) -> string | null\`.`,
488
+ );
489
+ }
490
+ return transform;
491
+ }
492
+
424
493
  /**
425
494
  * `assets` — stage the repository's static files into the package root.
426
495
  *
@@ -440,37 +509,22 @@ function assetsCommand() {
440
509
  builder: (y) => y,
441
510
  handler: handler(async () => {
442
511
  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"));
512
+ const packConfig = loadPackConfig();
513
+ const assetsBase = packConfig.paths.assets;
514
+ const findings = [
515
+ ...pictureRootRowFindings(config.assets, {
516
+ rootDir: config.rootDir,
517
+ assetsBase,
518
+ }),
519
+ ...checkForeignAssetBindings(packConfig, { assets: config.assets }),
520
+ ];
521
+ if (findings.length) {
522
+ const error = new Error(findings.map(formatDiagnostic).join("\n"));
452
523
  error.located = true;
453
524
  die(error);
454
525
  }
455
526
 
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
- }
527
+ const transform = await loadAssetTransform(config);
474
528
 
475
529
  // `to:` is relative to the staged package root, so a
476
530
  // repository's table reads `lang`, not `build/stage/lang`.
@@ -483,6 +537,35 @@ function assetsCommand() {
483
537
  transform,
484
538
  });
485
539
 
540
+ // The picture roots, staged by the toolchain rather than by a row:
541
+ // flat, filtered by each file's provenance, and listed in the
542
+ // package's asset set.
543
+ const pictures = stagePackageAssets({
544
+ assetsBase,
545
+ contentPackage: packConfig.contentPackage,
546
+ stageDir: path.resolve(config.rootDir, config.stageDir),
547
+ // The manifest's title names the set; a package with none is
548
+ // named by its content package.
549
+ title:
550
+ typeof config.manifest?.title === "string" && config.manifest.title.trim() ?
551
+ config.manifest.title
552
+ : packConfig.contentPackage,
553
+ transform,
554
+ shippingLicenses: config.shippingLicenses,
555
+ });
556
+ if (pictures.findings.length) {
557
+ const error = new Error(pictures.findings.map(formatDiagnostic).join("\n"));
558
+ error.located = true;
559
+ die(error);
560
+ }
561
+ files += pictures.staged.length;
562
+ if (pictures.staged.length || pictures.excluded.length) {
563
+ console.log(
564
+ `Pictures: ${pictures.staged.length} staged, ` +
565
+ `${pictures.excluded.length} left out by provenance.`,
566
+ );
567
+ }
568
+
486
569
  // Staged in its own pass, without the repository's transform: the
487
570
  // sheet is this toolchain's file rather than one of the
488
571
  // repository's assets, and a `transform` written to retheme a
@@ -1464,6 +1547,9 @@ function releaseCommand() {
1464
1547
  }),
1465
1548
  );
1466
1549
  }
1550
+ for (const archive of result.pictures ?? []) {
1551
+ console.log(`🖼 Pictures: ${path.relative(config.rootDir, archive)}`);
1552
+ }
1467
1553
  for (const pdf of result.pdfs ?? []) {
1468
1554
  console.log(`📕 Book: ${path.relative(config.rootDir, pdf)}`);
1469
1555
  }
@@ -1534,6 +1620,150 @@ function deployCommand() {
1534
1620
  };
1535
1621
  }
1536
1622
 
1623
+ /**
1624
+ * `cdn publish` — upload every picture the package has to the CDN, and the art
1625
+ * the package leaves out as a named asset set.
1626
+ *
1627
+ * The one writer to `<site.assets>/<package>/`. It is run by hand or by a
1628
+ * dispatched workflow, never by a build or a release. The store is named by
1629
+ * the `CDN_*` variables in `CDN_ENV`, read from the environment or
1630
+ * `.env.local`; a dry run with none of them set compares against the public
1631
+ * CDN instead.
1632
+ *
1633
+ * @returns {object} The yargs command module.
1634
+ */
1635
+ function cdnPublishCommand() {
1636
+ return {
1637
+ command: "publish",
1638
+ describe: "Upload every picture to the CDN and write a named asset set",
1639
+ builder: (y) =>
1640
+ y
1641
+ .option("set", {
1642
+ type: "string",
1643
+ demandOption: true,
1644
+ describe: "The set file's name under the package's CDN root, e.g. ai-art.json",
1645
+ })
1646
+ .option("name", {
1647
+ type: "string",
1648
+ demandOption: true,
1649
+ describe: "The set's display name",
1650
+ })
1651
+ .option("dry-run", {
1652
+ type: "boolean",
1653
+ default: false,
1654
+ describe: "List what would upload and what is skipped; write nothing",
1655
+ }),
1656
+ handler: handler(async (args) => {
1657
+ const config = loadPackageBuildConfig();
1658
+ const packConfig = loadPackConfig();
1659
+ await loadEnvironment(config);
1660
+
1661
+ const setProblem = setFileProblem(args.set);
1662
+ if (setProblem) die(setProblem);
1663
+ const name = String(args.name ?? "").trim();
1664
+ if (!name) die("`--name` is blank; a set needs a display name");
1665
+ const host = String(packConfig.site?.assets ?? "").trim();
1666
+ if (!host) {
1667
+ die(
1668
+ "`site.assets` is not declared, and it is the CDN root every picture " +
1669
+ "is published under",
1670
+ );
1671
+ }
1672
+ const credentials = cdnCredentials(process.env);
1673
+ if (!credentials && !args.dryRun) {
1674
+ die(
1675
+ `no CDN store is configured: set ${Object.values(CDN_ENV).join(", ")} ` +
1676
+ "in the environment or `.env.local`",
1677
+ );
1678
+ }
1679
+
1680
+ const plan = planCdnPublish({
1681
+ assetsBase: packConfig.paths.assets,
1682
+ contentPackage: packConfig.contentPackage,
1683
+ host,
1684
+ setFile: args.set,
1685
+ setName: name,
1686
+ transform: await loadAssetTransform(config),
1687
+ shippingLicenses: config.shippingLicenses,
1688
+ });
1689
+ if (plan.findings.length) {
1690
+ const located = plan.findings.map((finding) => ({
1691
+ ...finding,
1692
+ file:
1693
+ path.isAbsolute(finding.file) ?
1694
+ path.relative(process.cwd(), finding.file)
1695
+ : finding.file,
1696
+ }));
1697
+ reportFindings(located, {});
1698
+ process.exitCode = 1;
1699
+ return;
1700
+ }
1701
+ const excluded = new Set(
1702
+ plan.uploads.filter((upload) => !upload.shipped).map((upload) => upload.record),
1703
+ );
1704
+ const references = noteArtReferences({
1705
+ contentBase: packConfig.paths.content,
1706
+ skipDirectories: packConfig.skipDirectories,
1707
+ contentPackage: packConfig.contentPackage,
1708
+ records: plan.records,
1709
+ });
1710
+
1711
+ const store = credentials ? s3Store(credentials) : publicStore(host);
1712
+ console.log(
1713
+ `${args.dryRun ? "Dry run: comparing" : "Publishing"} ${plan.uploads.length} ` +
1714
+ `pictures against ${store.describe}.`,
1715
+ );
1716
+ const result = await publishToCdn(plan, {
1717
+ store,
1718
+ dryRun: args.dryRun,
1719
+ log: (line) => console.log(` ${line}`),
1720
+ });
1721
+ const failures = result.failed.map(({ upload, message }) => ({
1722
+ file: path.relative(
1723
+ process.cwd(),
1724
+ path.join(packConfig.paths.assets, ...upload.record.asset.path.split("/")),
1725
+ ),
1726
+ severity: "error",
1727
+ message: `${upload.key} did not upload: ${message}`,
1728
+ }));
1729
+ const failedCount = reportFindings(
1730
+ [
1731
+ ...failures,
1732
+ ...noteArtFindings(references, {
1733
+ contentPackage: packConfig.contentPackage,
1734
+ published: result.published,
1735
+ excluded,
1736
+ }),
1737
+ ],
1738
+ {},
1739
+ );
1740
+ if (failedCount > 0) process.exitCode = 1;
1741
+ const set = /** @type {NonNullable<typeof plan.set>} */ (plan.set);
1742
+ const setState =
1743
+ result.set === "skipped" ? "unchanged"
1744
+ : result.set === "withheld" ? "withheld"
1745
+ : args.dryRun ? "would change"
1746
+ : "written";
1747
+ console.log(
1748
+ `${args.dryRun ? "Would upload" : "Uploaded"} ${result.uploaded.length}, ` +
1749
+ `skipped ${result.skipped.length}, failed ${result.failed.length}; ` +
1750
+ `${plan.cdnRoot}${args.set} lists ` +
1751
+ `${set.set.assets.length} left-out pictures (${setState}).`,
1752
+ );
1753
+ }),
1754
+ };
1755
+ }
1756
+
1757
+ /** Publish the package's pictures to the CDN. */
1758
+ function cdnCommand() {
1759
+ return {
1760
+ command: "cdn <action>",
1761
+ describe: "Publish the package's pictures to the CDN",
1762
+ builder: (yargs) => yargs.command(cdnPublishCommand()).demandCommand(1).strict(),
1763
+ handler: () => {},
1764
+ };
1765
+ }
1766
+
1537
1767
  /**
1538
1768
  * `container <stage> <action>` — run a stage's Foundry in a container.
1539
1769
  *
@@ -1749,6 +1979,7 @@ registerContentCommands(
1749
1979
  .command(bundleCommand())
1750
1980
  .command(releaseCommand())
1751
1981
  .command(deployCommand())
1982
+ .command(cdnCommand())
1752
1983
  .command(containerCommand())
1753
1984
  .command(e2eCommand()),
1754
1985
  )
@@ -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
+ }
package/config.mjs CHANGED
@@ -57,6 +57,7 @@
57
57
  */
58
58
 
59
59
  import path from "node:path";
60
+ import { shippingLicensesProblem } from "./engine/asset-types.mjs";
60
61
  import { fileURLToPath } from "node:url";
61
62
  import { loadPackConfig, locateConfigError, packConfigPath } from "./engine/pack-config.mjs";
62
63
 
@@ -65,6 +66,7 @@ const SECTION_KEYS = [
65
66
  "stageDir",
66
67
  "assets",
67
68
  "assetTransform",
69
+ "shippingLicenses",
68
70
  "baseStyles",
69
71
  "manifest",
70
72
  "manifestFlags",
@@ -556,6 +558,8 @@ function normalizeExceptions(value, field, where) {
556
558
  * @property {string} stageDir The staged package root, relative to
557
559
  * `rootDir`. Every asset `to:` lands under it.
558
560
  * @property {readonly Readonly<AssetSpec>[]} assets
561
+ * @property {readonly string[]} shippingLicenses Licences this package adds to the
562
+ * default shipping list.
559
563
  * @property {boolean} baseStyles Whether the package takes the shared base
560
564
  * stylesheet, staged into `stageDir` and
561
565
  * named first in the manifest's `styles`.
@@ -785,6 +789,9 @@ export function resolvePackageBuildConfig(shared) {
785
789
  }
786
790
  const assets = (section.assets ?? []).map(normalizeAsset);
787
791
 
792
+ const shippingProblem = shippingLicensesProblem(section.shippingLicenses);
793
+ if (shippingProblem) fail("packageBuild.shippingLicenses", shippingProblem);
794
+
788
795
  const clean = section.clean ?? {};
789
796
  if (!isMapping(clean)) fail("packageBuild.clean", "must be a mapping");
790
797
  rejectUnknownKeys(
@@ -899,6 +906,9 @@ export function resolvePackageBuildConfig(shared) {
899
906
  ARTIFACT_OF_KIND[/** @type {"systems"|"modules"} */ (shared.packageKind)]
900
907
  : requireNonEmptyString(releaseInput.artifact, "packageBuild.release.artifact"),
901
908
  assets: Object.freeze(assets),
909
+ shippingLicenses: Object.freeze([
910
+ .../** @type {string[]} */ (section.shippingLicenses ?? []),
911
+ ]),
902
912
  // On by default, because the rules it carries are what compiled content
903
913
  // needs to be legible and a package that declined them by omission
904
914
  // would ship the content unstyled without anything saying so. A package