@heroiclands/package-build 22.0.1 → 22.0.3

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 CHANGED
@@ -1,5 +1,43 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.0.3
4
+
5
+ ### Patch Changes
6
+
7
+ - fd58dc0: **A package may ship assets and compile nothing.** `packs: []` is now a package
8
+ saying it has no documents, rather than one that forgot to say which, and a
9
+ package with no packs needs no content tree — its index is its asset records.
10
+ An alternative-art module is the case: the same addresses another package
11
+ publishes, resolving to different files when it is installed.
12
+
13
+ A package that _does_ declare packs is unchanged: a missing content tree is
14
+ still a misconfigured path and still fails, and an index that would state a
15
+ package has no content at all is still refused.
16
+ - cfdf061: **Two pieces of per-repository tooling move here.** Every site-publishing
17
+ package held its own copy of both, and a copy per consumer is a copy free to
18
+ drift — which is what happened, invisibly, because nobody reads all of them at
19
+ once.
20
+
21
+ - `packageBuild.assetTransform: svg-theme` names a shipped transform, so an
22
+ icon follows the reader's colour scheme without a module in the repository.
23
+ The value still takes a path, so a consumer with a transform of its own is
24
+ unaffected.
25
+ - `package-build site-root` writes a deployment's `_headers` and `_redirects`:
26
+ indexing suppressed on every host-assigned address, and both spellings of the
27
+ prefix root redirected to the landing with a lifetime on the 301. The package
28
+ name comes from `contentPackage`, which was the only thing the copies actually
29
+ varied.
30
+
31
+ ## 22.0.2
32
+
33
+ ### Patch Changes
34
+
35
+ - 83fa58e: **A record states `attribution` and `license`, or it is a finding.** The nearest
36
+ provenance record is the whole answer and is never merged with the one above it,
37
+ so a key left out is not inherited — it is absent, and a record stating neither
38
+ would leave every file beneath it with no rights holder and no terms. `source`,
39
+ `ai` and `notes` stay optional, because blank is a truthful answer for each.
40
+
3
41
  ## 22.0.1
4
42
 
5
43
  ### Patch Changes
@@ -78,6 +78,7 @@ import yargs from "yargs";
78
78
  import { hideBin } from "yargs/helpers";
79
79
 
80
80
  import { loadPackageBuildConfig } from "../config.mjs";
81
+ import { writeSiteRoot } from "../engine/site-root.mjs";
81
82
  import { compilesFoundryDocuments } from "../content-config.mjs";
82
83
  import { loadPackConfig, packConfigPath } from "../engine/pack-config.mjs";
83
84
  import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
@@ -466,6 +467,37 @@ function manifestCommand() {
466
467
  };
467
468
  }
468
469
 
470
+ /**
471
+ * `package-build site-root` — the deployment's root files.
472
+ *
473
+ * Hugo owns everything under the `/<package>/` prefix; this owns what sits
474
+ * beside it, which is the pair Cloudflare Pages reads from the uploaded
475
+ * directory and nowhere else.
476
+ *
477
+ * @returns {object} The yargs command.
478
+ */
479
+ function siteRootCommand() {
480
+ return {
481
+ command: "site-root",
482
+ describe: "Write the deployment's _headers and _redirects",
483
+ builder: (y) =>
484
+ y.option("out", {
485
+ type: "string",
486
+ describe: "The directory that is deployed (default: build/site)",
487
+ }),
488
+ handler: handler(async (argv) => {
489
+ const config = loadPackageBuildConfig();
490
+ const shared = loadPackConfig();
491
+ const out = path.resolve(config.rootDir, argv.out ?? "build/site");
492
+
493
+ const { files } = writeSiteRoot({ pkg: shared.contentPackage, out });
494
+ for (const file of files) {
495
+ console.log(`✅ Wrote ${path.relative(config.rootDir, file)}.`);
496
+ }
497
+ }),
498
+ };
499
+ }
500
+
469
501
  /**
470
502
  * Every file matching a glob, as `{ path, text }` with the path relative to the
471
503
  * repository root — the form both the rules and the findings want.
@@ -1214,6 +1246,7 @@ yargs(hideBin(process.argv))
1214
1246
  .command(cleanCommand())
1215
1247
  .command(assetsCommand())
1216
1248
  .command(manifestCommand())
1249
+ .command(siteRootCommand())
1217
1250
  .command(schemaCommand())
1218
1251
  .command(langCommand())
1219
1252
  .command(labelsCommand())
package/config.mjs CHANGED
@@ -57,6 +57,7 @@
57
57
  */
58
58
 
59
59
  import path from "node:path";
60
+ import { fileURLToPath } from "node:url";
60
61
  import { loadPackConfig, locateConfigError, packConfigPath } from "./engine/pack-config.mjs";
61
62
 
62
63
  /** Keys the reserved section may declare. */
@@ -565,6 +566,39 @@ function normalizeExceptions(value, field, where) {
565
566
  * collections, as collection → source directory.
566
567
  */
567
568
 
569
+ /**
570
+ * The asset transforms this package ships, by the name a consumer writes.
571
+ *
572
+ * `packageBuild.assetTransform` takes either one of these names or a path to a
573
+ * module of the consumer's own. A name is the answer where every package wants
574
+ * the same behaviour — theming an icon to the reader's colour scheme is not a
575
+ * per-package decision, and a copy in each consumer is a copy that drifts.
576
+ *
577
+ * @type {Readonly<Record<string, string>>}
578
+ */
579
+ export const BUILT_IN_ASSET_TRANSFORMS = Object.freeze({
580
+ "svg-theme": "./engine/svg-theme.mjs",
581
+ });
582
+
583
+ /**
584
+ * Where a declared `assetTransform` is loaded from.
585
+ *
586
+ * A built-in name resolves to the module this package ships. Anything else is a
587
+ * path, resolved against the repository root the way it always was — so a
588
+ * consumer with a transform of its own is unaffected.
589
+ *
590
+ * @param {string} declared - The authored value.
591
+ * @param {string} rootDir - The repository root.
592
+ * @returns {string} An absolute path to import.
593
+ */
594
+ export function resolveAssetTransform(declared, rootDir) {
595
+ const builtIn = BUILT_IN_ASSET_TRANSFORMS[declared];
596
+ if (builtIn) {
597
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), builtIn);
598
+ }
599
+ return path.resolve(rootDir, declared);
600
+ }
601
+
568
602
  /**
569
603
  * Resolve a package-build configuration from an already-loaded shared one.
570
604
  *
@@ -703,9 +737,9 @@ export function resolvePackageBuildConfig(shared) {
703
737
  assetTransform:
704
738
  section.assetTransform === undefined ?
705
739
  null
706
- : path.resolve(
707
- shared.rootDir,
740
+ : resolveAssetTransform(
708
741
  requireNonEmptyString(section.assetTransform, "packageBuild.assetTransform"),
742
+ shared.rootDir,
709
743
  ),
710
744
  manifest: normalizeManifest(section.manifest),
711
745
  manifestFlags:
@@ -2328,10 +2328,17 @@ export function defineConfig(config) {
2328
2328
  }
2329
2329
  }
2330
2330
 
2331
- if (!documentation) {
2332
- if (!Array.isArray(input.packs)) fail("packs", "must be an array");
2333
- if (input.packs.length === 0) fail("packs", "must declare at least one pack");
2334
- }
2331
+ // A Foundry package need not compile anything. A module may ship assets and
2332
+ // nothing else — alternative art for another package is the case, and it is
2333
+ // an ordinary module that installs, is enabled, and supplies files. So
2334
+ // `packs: []` is a package saying it compiles no documents, not a package
2335
+ // that forgot to say which.
2336
+ //
2337
+ // What that leaves uncovered is a tree of notes with no pack to compile them
2338
+ // into, which would be silently ignored. `packs` cannot see the tree, so the
2339
+ // walk reports it: a declared pack that compiles nothing from a non-empty
2340
+ // tree already fails, and so does a note whose `pack:` names none.
2341
+ if (!documentation && !Array.isArray(input.packs)) fail("packs", "must be an array");
2335
2342
  const declaredPacks = Array.isArray(input.packs) ? input.packs : [];
2336
2343
  const packs = declaredPacks.map((pack, index) => normalizePack(pack, `packs[${index}]`));
2337
2344
 
package/docs/api.md CHANGED
@@ -567,13 +567,14 @@ The asset types — `icon`, `image` and `audio` — and the three roots they are
567
567
 
568
568
  The asset record: one line of the content index per addressable file. The record is emitted by the package holding the bytes, so its `path` is that package's path and each consumer joins its own root onto it — Foundry its data directory, the website its CDN prefix, the book its asset base.
569
569
 
570
- | Export | Signature | Returns | Use it when |
571
- | --------------------------- | --------------------------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------- |
572
- | `ASSET_RECORD_FIELDS` | `const ASSET_RECORD_FIELDS` | `readonly AssetRecordField[]` | reading what an `asset` block carries, and where each field comes from |
573
- | `PROVENANCE_KEYS` | `const PROVENANCE_KEYS` | `ReadonlySet<string>` | validating a provenance file, whose keys are derived from the record's own fields |
574
- | `PROVENANCE_FILE` | `const PROVENANCE_FILE` | `string` `provenance.yaml` | naming the file a directory records provenance for its subtree in |
575
- | `PROVENANCE_SIDECAR_SUFFIX` | `const PROVENANCE_SIDECAR_SUFFIX` | `string` — `.yaml` | naming a per-file record, which replaces an inherited one wholesale |
576
- | `collectAssetRecords` | `collectAssetRecords(assetsBase, { contentPackage, problems })` | `Array<Record<string, any>>` | reading a package's asset roots into index records, without walking its notes |
570
+ | Export | Signature | Returns | Use it when |
571
+ | --------------------------- | --------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
572
+ | `ASSET_RECORD_FIELDS` | `const ASSET_RECORD_FIELDS` | `readonly AssetRecordField[]` | reading what an `asset` block carries, and where each field comes from |
573
+ | `PROVENANCE_KEYS` | `const PROVENANCE_KEYS` | `ReadonlySet<string>` | validating a provenance file, whose keys are derived from the record's own fields |
574
+ | `REQUIRED_PROVENANCE_KEYS` | `const REQUIRED_PROVENANCE_KEYS` | `ReadonlySet<string>` | the keys a provenance file must state — a record resolves wholesale, so one omitting them leaves every file it covers with no rights holder or no terms |
575
+ | `PROVENANCE_FILE` | `const PROVENANCE_FILE` | `string` — `provenance.yaml` | naming the file a directory records provenance for its subtree in |
576
+ | `PROVENANCE_SIDECAR_SUFFIX` | `const PROVENANCE_SIDECAR_SUFFIX` | `string` — `.yaml` | naming a per-file record, which replaces an inherited one wholesale |
577
+ | `collectAssetRecords` | `collectAssetRecords(assetsBase, { contentPackage, problems })` | `Array<Record<string, any>>` | reading a package's asset roots into index records, without walking its notes |
577
578
 
578
579
  ### `engine.packages`
579
580
 
@@ -1487,6 +1488,8 @@ console.log(normalizeRepoUrl("git@github.com:HeroicLands/sohl.git"));
1487
1488
  | ------------------------------ | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1488
1489
  | `ARTIFACTS` | `const ARTIFACTS` | — | reading the two Foundry package kinds (`"system"`, `"module"`) as the artifact name their manifest and release archive are called |
1489
1490
  | `normalizeRepoUrl` | `normalizeRepoUrl(repository)` | `string` — normalised `https://` URL, no trailing slash | normalising `package.json`'s `repository` field (object or shorthand string, `git+…git` or plain) to the exact URL Foundry fetches release assets from |
1491
+ | `BUILT_IN_ASSET_TRANSFORMS` | `const BUILT_IN_ASSET_TRANSFORMS` | `Readonly<Record<string, string>>` | the asset transforms this package ships, by the name `packageBuild.assetTransform` writes — `svg-theme` themes an icon to the reader's colour scheme |
1492
+ | `resolveAssetTransform` | `resolveAssetTransform(declared, rootDir)` | `string` — an absolute path to import | resolving a declared transform: a built-in name reaches the shipped module, anything else is a path against the repository root |
1490
1493
  | `HOMEPAGE_ORIGIN` | `HOMEPAGE_ORIGIN` | `string` — the origin every package's homepage is served from | stating the site origin once, so the address a manifest advertises and the address the configuration documents cannot disagree |
1491
1494
  | `packageHomepage` | `packageHomepage(contentPackage)` | `string` — `<origin>/<contentPackage>/` | deriving the homepage a manifest's `url` points at; throws when given no name rather than advertising `<origin>/undefined/` |
1492
1495
  | `releaseUrls` | `releaseUrls({ repoUrl, homeUrl, version, artifact })` | `{url: string, bugs: string, manifest: string, download: string}` | building the manifest's addresses — `url` is the homepage a reader follows before installing, while `bugs`, `manifest` and `download` stay on the repository holding the artefacts |
package/docs/commands.md CHANGED
@@ -260,6 +260,60 @@ package-build: `packageKind: documentation` ships no Foundry package, so there i
260
260
  `content-build site`, `content-build pdf`, `package-build bundle check`,
261
261
  [Configuration](configuration.md).
262
262
 
263
+ ### `package-build site-root`
264
+
265
+ **NAME**
266
+
267
+ `package-build site-root` — write the deployment's `_headers` and `_redirects`.
268
+
269
+ **SYNOPSIS**
270
+
271
+ ```
272
+ package-build site-root [--out <dir>]
273
+ ```
274
+
275
+ **DESCRIPTION**
276
+
277
+ Hugo renders into `<out>/<contentPackage>/`, because the deployment carries the
278
+ `/<contentPackage>/` prefix physically and the routing layer is a
279
+ path-preserving pass-through. The directory that is _uploaded_ is its parent,
280
+ and Cloudflare Pages reads `_headers` and `_redirects` from there and nowhere
281
+ else — a copy inside the prefix is published as a text file and never applied.
282
+ Hugo owns everything under the prefix; this owns what sits beside it.
283
+
284
+ Two things are written. Indexing is suppressed on every address a deployment
285
+ answers on but nobody advertises — the project's `pages.dev`, the per-deployment
286
+ `pages.dev`, and the custom domain the routing layer fetches — each of which
287
+ would otherwise compete with the canonical URL in search results. And both
288
+ spellings of the prefix root redirect to the landing, with a lifetime pinned on
289
+ the 301, because Pages sets no `Cache-Control` on a redirect it generates and a
290
+ 301 without one is cached indefinitely on the most-linked URL there is.
291
+
292
+ The rules are scoped to those hostnames, so a site deployed under a domain of
293
+ its own stays indexable.
294
+
295
+ **OPTIONS**
296
+
297
+ `--out <dir>` — the directory that is deployed. Defaults to `build/site`.
298
+
299
+ **EXIT STATUS**
300
+
301
+ 1 when `<out>/<contentPackage>/` holds no rendered site, which means the site
302
+ build has not run and writing root files would publish a deployment with nothing
303
+ under the prefix. Otherwise 0.
304
+
305
+ **EXAMPLES**
306
+
307
+ ```
308
+ $ package-build site-root
309
+ ✅ Wrote build/site/_headers.
310
+ ✅ Wrote build/site/_redirects.
311
+ ```
312
+
313
+ **SEE ALSO**
314
+
315
+ `content-build site`, [Configuration](configuration.md).
316
+
263
317
  `package-build lang <action>` asks three independent questions about this
264
318
  repository's localization, each blind to what the others see: `check`,
265
319
  `coverage` and `hardcoded`, one section below per action.
@@ -580,7 +580,9 @@ packs:
580
580
 
581
581
  > ``package-build config: `packs` must be an array.``
582
582
 
583
- > ``package-build config: `packs` must declare at least one pack.``
583
+ A package may declare **no** packs. A module that ships assets and compiles
584
+ nothing — alternative art for another package is the case — writes `packs: []`,
585
+ and the manifest carries an empty pack list.
584
586
 
585
587
  Two packs (including companions, anywhere in the tree) may not share a name
586
588
  — two packs both named `x` produce:
@@ -1508,6 +1508,17 @@ One address resolves its record in this order:
1508
1508
  3. Otherwise nothing, and the provenance fields are blank. A package that
1509
1509
  records no attribution is a fact to state, not a walk to fail.
1510
1510
 
1511
+ **The nearest record is the whole answer.** It is never merged with the one
1512
+ above it, so a record states every key it means to claim — which is what lets a
1513
+ reader open one file and know what the tree beneath it carries, instead of
1514
+ resolving a chain by hand.
1515
+
1516
+ That is also why `attribution` and `license` are **required in any record that
1517
+ exists**: a key left out is not inherited from above, it is simply absent, so a
1518
+ record stating neither leaves every file it covers with no rights holder and no
1519
+ terms. Omitting one is a finding. `source`, `ai` and `notes` stay optional,
1520
+ because a blank is a truthful answer for each of them.
1521
+
1511
1522
  #### A font is not an asset
1512
1523
 
1513
1524
  `assets/fonts` is not a root, and a font has no address.
@@ -88,6 +88,10 @@ export const PROVENANCE_SIDECAR_SUFFIX = ".yaml";
88
88
  * @property {string} name - The key inside `asset`.
89
89
  * @property {"walk"|"provenance"} from - Where the value comes from: the walk
90
90
  * itself, or the provenance record resolved for the address.
91
+ * @property {boolean} [required] - Whether a provenance record that exists must
92
+ * state this key. Omitting one is a finding rather than a blank, because a
93
+ * record resolves wholesale: the nearest one is the whole answer, so a key it
94
+ * leaves out is not inherited from above but simply absent.
91
95
  * @property {string} describe - One line, for the author-facing reference.
92
96
  */
93
97
 
@@ -119,6 +123,7 @@ export const ASSET_RECORD_FIELDS = Object.freeze([
119
123
  Object.freeze({
120
124
  name: "attribution",
121
125
  from: "provenance",
126
+ required: true,
122
127
  describe: "The person holding the rights, to whom attribution is legally due.",
123
128
  }),
124
129
  Object.freeze({
@@ -134,6 +139,7 @@ export const ASSET_RECORD_FIELDS = Object.freeze([
134
139
  Object.freeze({
135
140
  name: "license",
136
141
  from: "provenance",
142
+ required: true,
137
143
  describe: "The licence it is used under — an SPDX identifier, or terms.",
138
144
  }),
139
145
  Object.freeze({
@@ -159,6 +165,22 @@ export const PROVENANCE_KEYS = Object.freeze(
159
165
  ),
160
166
  );
161
167
 
168
+ /**
169
+ * The keys a provenance file must state.
170
+ *
171
+ * Derived from {@link ASSET_RECORD_FIELDS} for the same reason the key set is:
172
+ * the rule and the record cannot disagree about which keys are required.
173
+ *
174
+ * @type {ReadonlySet<string>}
175
+ */
176
+ export const REQUIRED_PROVENANCE_KEYS = Object.freeze(
177
+ new Set(
178
+ ASSET_RECORD_FIELDS.filter((field) => field.from === "provenance" && field.required).map(
179
+ (field) => field.name,
180
+ ),
181
+ ),
182
+ );
183
+
162
184
  /**
163
185
  * Read one provenance file, reporting every key that is not a provenance key.
164
186
  *
@@ -218,6 +240,25 @@ function readProvenanceFile(file, findings) {
218
240
  }
219
241
  out[key] = value == null ? "" : String(value);
220
242
  }
243
+
244
+ // A record resolves wholesale — the nearest one is the whole answer, and no
245
+ // ancestor fills a key it leaves out. So an omitted `attribution` or
246
+ // `license` is not a value inherited from above; it is a file with no stated
247
+ // rights holder and no stated terms, which is the one thing a provenance
248
+ // record exists to prevent. Reported against the file rather than a line,
249
+ // because the fault is an absence and has no position.
250
+ for (const key of REQUIRED_PROVENANCE_KEYS) {
251
+ if (out[key]) continue;
252
+ findings.push({
253
+ file,
254
+ severity: "error",
255
+ message:
256
+ `a provenance record states \`${key}\`, and this one does not. ` +
257
+ "A record replaces rather than extends the one above it, so every " +
258
+ "file it covers would carry no " +
259
+ (key === "license" ? "licence" : key),
260
+ });
261
+ }
221
262
  return out;
222
263
  }
223
264
 
@@ -239,6 +280,9 @@ function inheritedProvenance(dir, root, cache, findings) {
239
280
  const own = path.join(dir, PROVENANCE_FILE);
240
281
  let answer;
241
282
  if (fs.existsSync(own)) {
283
+ // The nearest record is the whole answer. It is not merged with the one
284
+ // above it, so a record states every key it means to claim and a reader
285
+ // needs only the file in front of them to know what a file carries.
242
286
  answer = readProvenanceFile(own, findings);
243
287
  } else if (path.resolve(dir) === path.resolve(root)) {
244
288
  // The search stops at the type root: `assets/` above it is the
@@ -567,10 +567,13 @@ export function collectContentIndex(
567
567
  // caller's omission, and `walkMarkdownTree` says so.
568
568
  const walkOpts = { skipDirectories };
569
569
 
570
- for (const { frontmatter, body, bodyLine, absPath } of walkMarkdownTree(
571
- contentBase,
572
- walkOpts,
573
- )) {
570
+ // A package may ship assets and no notes at all, in which case there is no
571
+ // tree to walk and the index is its asset records. The caller decides
572
+ // whether an absent tree is a mistake; by the time the walk is reached it
573
+ // is simply a package with nothing to compile.
574
+ const notes = fs.existsSync(contentBase) ? walkMarkdownTree(contentBase, walkOpts) : [];
575
+
576
+ for (const { frontmatter, body, bodyLine, absPath } of notes) {
574
577
  const fm = frontmatter ?? {};
575
578
  // The id the note's document is filed under, resolved before
576
579
  // the record is built so the index publishes the address *and* the id
@@ -674,6 +677,20 @@ export function serializeContentIndex(records) {
674
677
  return `${records.map((r) => JSON.stringify(r)).join("\n")}\n`;
675
678
  }
676
679
 
680
+ /**
681
+ * Whether an absent content tree is a mistake.
682
+ *
683
+ * A package that declares a pack has notes to compile into it, so a missing
684
+ * tree is a misconfigured path and the build says so. A package that declares
685
+ * none ships assets and nothing else, and its index is its asset records.
686
+ *
687
+ * @param {object} resolved - The resolved configuration.
688
+ * @returns {boolean} Whether a tree is required.
689
+ */
690
+ function needsContentTree(resolved) {
691
+ return (resolved.packs ?? []).length > 0;
692
+ }
693
+
677
694
  /**
678
695
  * The index records for a content tree, without writing anything.
679
696
  *
@@ -711,7 +728,9 @@ export function indexRecordsFor({
711
728
  } = {}) {
712
729
  const resolved = config ?? loadPackConfig();
713
730
  const tree = contentBase ?? resolved.paths.content;
714
- if (!fs.existsSync(tree)) throw new Error(`no content tree at ${tree}`);
731
+ if (!fs.existsSync(tree) && needsContentTree(resolved)) {
732
+ throw new Error(`no content tree at ${tree}`);
733
+ }
715
734
  return collectContentIndex(tree, {
716
735
  contentPackage: resolved.contentPackage,
717
736
  skipDirectories: skipDirectories ?? resolved.skipDirectories,
@@ -745,7 +764,7 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
745
764
  const dir = outDir ?? resolved.paths.contentIndex;
746
765
  const contentPackage = resolved.contentPackage;
747
766
 
748
- if (!fs.existsSync(tree)) {
767
+ if (!fs.existsSync(tree) && needsContentTree(resolved)) {
749
768
  throw new Error(`no content tree at ${tree}`);
750
769
  }
751
770
 
@@ -0,0 +1,165 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ *
4
+ * SPDX-License-Identifier: GPL-3.0-or-later
5
+ */
6
+
7
+ /**
8
+ * The files that belong to a deployment's **root** rather than to the rendered
9
+ * site.
10
+ *
11
+ * Hugo renders into `<out>/<package>/`, because the deployment carries the
12
+ * `/<package>/` prefix physically and the routing layer is a path-preserving
13
+ * pass-through. The directory that is *uploaded* is its parent, and Cloudflare
14
+ * Pages reads `_headers` and `_redirects` from there and nowhere else — a copy
15
+ * inside the prefix is published as a text file and never applied. Hugo owns
16
+ * everything under the prefix; this owns what sits beside it.
17
+ *
18
+ * **One implementation, because it is one policy.** What is indexable, where
19
+ * the prefix root sends a reader, and how long that answer is cached are
20
+ * decisions about the hosting rather than about any one package. Held in each
21
+ * consumer they are the same file with one constant changed, which is a file
22
+ * that drifts — and the drift is invisible, because nobody reads all of the
23
+ * copies at once.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+
31
+ /**
32
+ * The namespace the routing layer derives a package's origin in.
33
+ *
34
+ * `/<package>/` on the public host is proxied to
35
+ * `https://<package>.<suffix>/<package>/`, and {@link noindexHeaders} depends on
36
+ * that being a dedicated namespace.
37
+ *
38
+ * @type {string}
39
+ */
40
+ export const ORIGIN_SUFFIX = "pkg.heroiclands.org";
41
+
42
+ /**
43
+ * Where a package's landing is served, now that it is an addressed page.
44
+ *
45
+ * The site build emits the homepage at its own address rather than as the
46
+ * site root's `_index.md`, so the prefix root is a redirect to it.
47
+ *
48
+ * @param {string} pkg - The content package name.
49
+ * @returns {string} The landing's path.
50
+ */
51
+ export function landingPath(pkg) {
52
+ return `/${pkg}/homepage-root/`;
53
+ }
54
+
55
+ /**
56
+ * Suppress indexing of every address a deployment answers on but nobody
57
+ * advertises.
58
+ *
59
+ * Cloudflare Pages assigns three: the project's own `pages.dev`, a per-
60
+ * deployment `pages.dev`, and the custom domain the project carries so the
61
+ * routing layer has an origin to fetch. None is advertised, all answer with the
62
+ * same pages, and left alone they are indexed and compete with the canonical
63
+ * URL in search results.
64
+ *
65
+ * The third matters most: it is the address the routing layer fetches, so it is
66
+ * the host-assigned address a reader is most plausibly handed. The rules are
67
+ * **scoped to those hostnames**, which keeps this correct for anyone deploying
68
+ * the site under a domain of their own — there it is indexable, and only the
69
+ * host-assigned addresses are not.
70
+ *
71
+ * @returns {string[]} The header block's lines.
72
+ */
73
+ export function noindexHeaders() {
74
+ return [
75
+ "https://:project.pages.dev/*",
76
+ " X-Robots-Tag: noindex",
77
+ "",
78
+ "https://:version.:project.pages.dev/*",
79
+ " X-Robots-Tag: noindex",
80
+ "",
81
+ `https://:package.${ORIGIN_SUFFIX}/*`,
82
+ " X-Robots-Tag: noindex",
83
+ "",
84
+ ];
85
+ }
86
+
87
+ /**
88
+ * The lifetime pinned on the prefix-root redirect, and why it is pinned.
89
+ *
90
+ * Cloudflare Pages sets no `Cache-Control` on a redirect it generates — those
91
+ * responses carry `location` and nothing else — and a 301 with no lifetime is
92
+ * cached by a browser indefinitely, on the most-linked URL there is. An hour
93
+ * keeps the 301's canonical signal without the permanence.
94
+ *
95
+ * @param {string} pkg - The content package name.
96
+ * @returns {string[]} The header block's lines.
97
+ */
98
+ export function cacheHeaders(pkg) {
99
+ return [
100
+ `/${pkg}/`,
101
+ " Cache-Control: max-age=3600",
102
+ "",
103
+ `/${pkg}`,
104
+ " Cache-Control: max-age=3600",
105
+ "",
106
+ ];
107
+ }
108
+
109
+ /**
110
+ * Both forms of the prefix root, because Pages matches the raw path.
111
+ *
112
+ * Redirect matching runs before any trailing-slash or `index.html` handling, so
113
+ * `/<pkg>` and `/<pkg>/` are distinct keys and a rule on one does not catch the
114
+ * other.
115
+ *
116
+ * @param {string} pkg - The content package name.
117
+ * @returns {string} The `_redirects` file's contents.
118
+ */
119
+ export function redirects(pkg) {
120
+ const to = landingPath(pkg);
121
+ return [`/${pkg}/ ${to} 301`, `/${pkg} ${to} 301`, ""].join("\n");
122
+ }
123
+
124
+ /**
125
+ * The `_headers` file's contents.
126
+ *
127
+ * @param {string} pkg - The content package name.
128
+ * @returns {string} The file's contents.
129
+ */
130
+ export function headers(pkg) {
131
+ return [...noindexHeaders(), ...cacheHeaders(pkg)].join("\n");
132
+ }
133
+
134
+ /**
135
+ * Write `_headers` and `_redirects` beside the rendered site.
136
+ *
137
+ * @param {object} options - Options.
138
+ * @param {string} options.pkg - The content package name, which is also the
139
+ * directory Hugo rendered into.
140
+ * @param {string} options.out - The directory that is deployed.
141
+ * @returns {{files: string[]}} The files written.
142
+ * @throws {Error} When no rendered site is there, which means the site build
143
+ * has not run and writing root files would publish a deployment with nothing
144
+ * under the prefix.
145
+ */
146
+ export function writeSiteRoot({ pkg, out }) {
147
+ const root = path.resolve(out);
148
+ const rendered = path.join(root, pkg);
149
+ if (!fs.existsSync(path.join(rendered, "index.html"))) {
150
+ throw new Error(
151
+ `${out}/${pkg}/ holds no rendered site — build the site before its root files`,
152
+ );
153
+ }
154
+
155
+ const written = [];
156
+ for (const [name, body] of [
157
+ ["_headers", headers(pkg)],
158
+ ["_redirects", redirects(pkg)],
159
+ ]) {
160
+ const file = path.join(root, name);
161
+ fs.writeFileSync(file, body);
162
+ written.push(file);
163
+ }
164
+ return { files: written };
165
+ }
@@ -0,0 +1,140 @@
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
+ * A staged SVG follows the reader's colour scheme.
16
+ *
17
+ * An icon drawn as black line art disappears against a dark background, and
18
+ * Foundry themes its own chrome. So a shape that is explicitly black, or black
19
+ * by default because it declares no `fill`, gets a rule that paints it the
20
+ * ink colour for the scheme in use.
21
+ *
22
+ * **Named rather than pathed.** `packageBuild.assetTransform: svg-theme`
23
+ * reaches this module. Every package that themes its icons themes them the same
24
+ * way — the ink colours are the system's text tokens — so the transform is one
25
+ * implementation here rather than a copy in each consumer, where the copies
26
+ * drift and nobody sees all of them at once.
27
+ *
28
+ * @module
29
+ */
30
+
31
+ import { readFileSync } from "node:fs";
32
+
33
+ /**
34
+ * Iron-gall ink, and cream against a dark ground.
35
+ *
36
+ * These mirror `--sohl-color-text-primary` in the system's
37
+ * `scss/abstracts/_tokens.scss`. Stated once here, so a token change is one
38
+ * edit rather than one per consuming repository.
39
+ *
40
+ * @type {string}
41
+ */
42
+ const INK_LIGHT = "#211d16";
43
+
44
+ /** @type {string} */
45
+ const INK_DARK = "#ece3cf";
46
+
47
+ /**
48
+ * The shapes a theme rule may repaint.
49
+ *
50
+ * Explicitly black, or black by default for want of a `fill`. A shape painted
51
+ * some other colour is deliberate — a white highlight in a two-tone badge — and
52
+ * repainting it would destroy the drawing.
53
+ *
54
+ * @type {string}
55
+ */
56
+ const SELECTOR = [
57
+ '[fill="#000"]',
58
+ '[fill="#000000"]',
59
+ '[fill="black"]',
60
+ "path:not([fill])",
61
+ "rect:not([fill])",
62
+ "circle:not([fill])",
63
+ "ellipse:not([fill])",
64
+ "polygon:not([fill])",
65
+ "polyline:not([fill])",
66
+ "line:not([fill])",
67
+ "g:not([fill])",
68
+ ].join(",");
69
+
70
+ /** @type {string} */
71
+ const STYLE =
72
+ `<style>${SELECTOR}{fill:${INK_LIGHT}}` +
73
+ `@media(prefers-color-scheme:dark){${SELECTOR}{fill:${INK_DARK}}}</style>`;
74
+
75
+ /**
76
+ * Every `style="…"` attribute, capturing its declarations.
77
+ *
78
+ * @type {RegExp}
79
+ */
80
+ const STYLE_ATTR = /style\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
81
+
82
+ /**
83
+ * A `fill` *declaration* — the property itself, at the start or after a `;`.
84
+ *
85
+ * Deliberately not `\bfill\b`, which also matches `fill-rule`, `fill-opacity`
86
+ * and `paint-order: fill`, none of which set a colour.
87
+ *
88
+ * @type {RegExp}
89
+ */
90
+ const FILL_DECL = /(?:^|;)\s*fill\s*:/i;
91
+
92
+ /**
93
+ * Whether any shape sets its fill inline.
94
+ *
95
+ * @param {string} svg - The SVG source.
96
+ * @returns {boolean} Whether an inline `fill` declaration is present.
97
+ */
98
+ function hasInlineFill(svg) {
99
+ for (const [, dq, sq] of svg.matchAll(STYLE_ATTR)) {
100
+ if (FILL_DECL.test(dq ?? sq ?? "")) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ /**
106
+ * The SVG with a scheme-aware fill rule, or unchanged where one cannot apply.
107
+ *
108
+ * Three files are returned as they are. One already carrying a
109
+ * `prefers-color-scheme` rule is themed — by this pass on an earlier run, or by
110
+ * its author — and re-theming it would stack rules. One with an inline `fill`
111
+ * cannot be themed at all, because an inline declaration beats a `<style>` rule
112
+ * and the result would be a half-recoloured icon, which is worse than an
113
+ * unthemed one. One with no `<svg>` element is not an SVG.
114
+ *
115
+ * @param {string} svg - The SVG source.
116
+ * @returns {string} The themed source, or the input unchanged.
117
+ */
118
+ export function injectAdaptiveFill(svg) {
119
+ if (typeof svg !== "string") return svg;
120
+ if (svg.includes("prefers-color-scheme")) return svg;
121
+ if (hasInlineFill(svg)) return svg;
122
+
123
+ const open = svg.match(/<svg\b[^>]*>/i);
124
+ if (!open) return svg;
125
+
126
+ const at = open.index + open[0].length;
127
+ return svg.slice(0, at) + STYLE + svg.slice(at);
128
+ }
129
+
130
+ /**
131
+ * The staging hook: theme an SVG, and pass everything else through untouched.
132
+ *
133
+ * @param {string} sourcePath - The file being staged.
134
+ * @returns {string|null} The themed source, or `null` to stage the file as it
135
+ * is — which is what every non-SVG asset gets.
136
+ */
137
+ export function transform(sourcePath) {
138
+ if (!sourcePath.endsWith(".svg")) return null;
139
+ return injectAdaptiveFill(readFileSync(sourcePath, "utf8"));
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.0.1",
3
+ "version": "22.0.3",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -1,3 +1,53 @@
1
+ /**
2
+ * Where a declared `assetTransform` is loaded from.
3
+ *
4
+ * A built-in name resolves to the module this package ships. Anything else is a
5
+ * path, resolved against the repository root the way it always was — so a
6
+ * consumer with a transform of its own is unaffected.
7
+ *
8
+ * @param {string} declared - The authored value.
9
+ * @param {string} rootDir - The repository root.
10
+ * @returns {string} An absolute path to import.
11
+ */
12
+ export function resolveAssetTransform(declared: string, rootDir: string): string;
13
+ /**
14
+ * Resolve a package-build configuration from an already-loaded shared one.
15
+ *
16
+ * Separate from {@link loadPackageBuildConfig} because this half is pure: it
17
+ * reads no file and touches no environment, so the validation rules can be
18
+ * described directly by a test instead of through a fixture repository on
19
+ * disk. {@link loadPackageBuildConfig} is the same function with the loading
20
+ * put back.
21
+ *
22
+ * @param {object} shared - The resolved content configuration.
23
+ * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
24
+ * @throws {TypeError} When the reserved section declares something malformed.
25
+ */
26
+ export function resolvePackageBuildConfig(shared: object): Readonly<PackageBuildConfig>;
27
+ /**
28
+ * The repository's resolved package-build configuration.
29
+ *
30
+ * Read on call rather than at import, exactly as content-build resolves its
31
+ * own: importing a module of this package must not require a configuration to
32
+ * exist anywhere above it.
33
+ *
34
+ * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
35
+ * @throws {TypeError} When there is no configuration, or the reserved section
36
+ * declares something malformed.
37
+ */
38
+ export function loadPackageBuildConfig(): Readonly<PackageBuildConfig>;
39
+ /**
40
+ * Manifest keys a repository may **not** declare, because the build derives
41
+ * them and would only overwrite what was written.
42
+ *
43
+ * Silently overwriting is the failure this list exists to prevent: a
44
+ * `version` typed into the configuration would look authoritative, sit there
45
+ * unread, and disagree with the shipped package forever. Declaring one is an
46
+ * error naming the key and where the value actually comes from.
47
+ *
48
+ * @type {Readonly<Record<string, string>>}
49
+ */
50
+ export const DERIVED_MANIFEST_KEYS: Readonly<Record<string, string>>;
1
51
  /**
2
52
  * The resolved `packageBuild` section, every optional half filled in.
3
53
  *
@@ -65,43 +115,16 @@
65
115
  * collections, as collection → source directory.
66
116
  */
67
117
  /**
68
- * Resolve a package-build configuration from an already-loaded shared one.
118
+ * The asset transforms this package ships, by the name a consumer writes.
69
119
  *
70
- * Separate from {@link loadPackageBuildConfig} because this half is pure: it
71
- * reads no file and touches no environment, so the validation rules can be
72
- * described directly by a test instead of through a fixture repository on
73
- * disk. {@link loadPackageBuildConfig} is the same function with the loading
74
- * put back.
75
- *
76
- * @param {object} shared - The resolved content configuration.
77
- * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
78
- * @throws {TypeError} When the reserved section declares something malformed.
79
- */
80
- export function resolvePackageBuildConfig(shared: object): Readonly<PackageBuildConfig>;
81
- /**
82
- * The repository's resolved package-build configuration.
83
- *
84
- * Read on call rather than at import, exactly as content-build resolves its
85
- * own: importing a module of this package must not require a configuration to
86
- * exist anywhere above it.
87
- *
88
- * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
89
- * @throws {TypeError} When there is no configuration, or the reserved section
90
- * declares something malformed.
91
- */
92
- export function loadPackageBuildConfig(): Readonly<PackageBuildConfig>;
93
- /**
94
- * Manifest keys a repository may **not** declare, because the build derives
95
- * them and would only overwrite what was written.
96
- *
97
- * Silently overwriting is the failure this list exists to prevent: a
98
- * `version` typed into the configuration would look authoritative, sit there
99
- * unread, and disagree with the shipped package forever. Declaring one is an
100
- * error naming the key and where the value actually comes from.
120
+ * `packageBuild.assetTransform` takes either one of these names or a path to a
121
+ * module of the consumer's own. A name is the answer where every package wants
122
+ * the same behaviour theming an icon to the reader's colour scheme is not a
123
+ * per-package decision, and a copy in each consumer is a copy that drifts.
101
124
  *
102
125
  * @type {Readonly<Record<string, string>>}
103
126
  */
104
- export const DERIVED_MANIFEST_KEYS: Readonly<Record<string, string>>;
127
+ export const BUILT_IN_ASSET_TRANSFORMS: Readonly<Record<string, string>>;
105
128
  /**
106
129
  * One staging copy: a source path in the repository, and where it lands under
107
130
  * the staged package root.
@@ -43,6 +43,10 @@ export const PROVENANCE_SIDECAR_SUFFIX: string;
43
43
  * @property {string} name - The key inside `asset`.
44
44
  * @property {"walk"|"provenance"} from - Where the value comes from: the walk
45
45
  * itself, or the provenance record resolved for the address.
46
+ * @property {boolean} [required] - Whether a provenance record that exists must
47
+ * state this key. Omitting one is a finding rather than a blank, because a
48
+ * record resolves wholesale: the nearest one is the whole answer, so a key it
49
+ * leaves out is not inherited from above but simply absent.
46
50
  * @property {string} describe - One line, for the author-facing reference.
47
51
  */
48
52
  /**
@@ -72,6 +76,15 @@ export const ASSET_RECORD_FIELDS: readonly AssetRecordField[];
72
76
  * @type {ReadonlySet<string>}
73
77
  */
74
78
  export const PROVENANCE_KEYS: ReadonlySet<string>;
79
+ /**
80
+ * The keys a provenance file must state.
81
+ *
82
+ * Derived from {@link ASSET_RECORD_FIELDS} for the same reason the key set is:
83
+ * the rule and the record cannot disagree about which keys are required.
84
+ *
85
+ * @type {ReadonlySet<string>}
86
+ */
87
+ export const REQUIRED_PROVENANCE_KEYS: ReadonlySet<string>;
75
88
  /**
76
89
  * One field of the `asset` block.
77
90
  */
@@ -85,6 +98,13 @@ export type AssetRecordField = {
85
98
  * itself, or the provenance record resolved for the address.
86
99
  */
87
100
  from: "walk" | "provenance";
101
+ /**
102
+ * - Whether a provenance record that exists must
103
+ * state this key. Omitting one is a finding rather than a blank, because a
104
+ * record resolves wholesale: the nearest one is the whole answer, so a key it
105
+ * leaves out is not inherited from above but simply absent.
106
+ */
107
+ required?: boolean | undefined;
88
108
  /**
89
109
  * - One line, for the author-facing reference.
90
110
  */
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Where a package's landing is served, now that it is an addressed page.
3
+ *
4
+ * The site build emits the homepage at its own address rather than as the
5
+ * site root's `_index.md`, so the prefix root is a redirect to it.
6
+ *
7
+ * @param {string} pkg - The content package name.
8
+ * @returns {string} The landing's path.
9
+ */
10
+ export function landingPath(pkg: string): string;
11
+ /**
12
+ * Suppress indexing of every address a deployment answers on but nobody
13
+ * advertises.
14
+ *
15
+ * Cloudflare Pages assigns three: the project's own `pages.dev`, a per-
16
+ * deployment `pages.dev`, and the custom domain the project carries so the
17
+ * routing layer has an origin to fetch. None is advertised, all answer with the
18
+ * same pages, and left alone they are indexed and compete with the canonical
19
+ * URL in search results.
20
+ *
21
+ * The third matters most: it is the address the routing layer fetches, so it is
22
+ * the host-assigned address a reader is most plausibly handed. The rules are
23
+ * **scoped to those hostnames**, which keeps this correct for anyone deploying
24
+ * the site under a domain of their own — there it is indexable, and only the
25
+ * host-assigned addresses are not.
26
+ *
27
+ * @returns {string[]} The header block's lines.
28
+ */
29
+ export function noindexHeaders(): string[];
30
+ /**
31
+ * The lifetime pinned on the prefix-root redirect, and why it is pinned.
32
+ *
33
+ * Cloudflare Pages sets no `Cache-Control` on a redirect it generates — those
34
+ * responses carry `location` and nothing else — and a 301 with no lifetime is
35
+ * cached by a browser indefinitely, on the most-linked URL there is. An hour
36
+ * keeps the 301's canonical signal without the permanence.
37
+ *
38
+ * @param {string} pkg - The content package name.
39
+ * @returns {string[]} The header block's lines.
40
+ */
41
+ export function cacheHeaders(pkg: string): string[];
42
+ /**
43
+ * Both forms of the prefix root, because Pages matches the raw path.
44
+ *
45
+ * Redirect matching runs before any trailing-slash or `index.html` handling, so
46
+ * `/<pkg>` and `/<pkg>/` are distinct keys and a rule on one does not catch the
47
+ * other.
48
+ *
49
+ * @param {string} pkg - The content package name.
50
+ * @returns {string} The `_redirects` file's contents.
51
+ */
52
+ export function redirects(pkg: string): string;
53
+ /**
54
+ * The `_headers` file's contents.
55
+ *
56
+ * @param {string} pkg - The content package name.
57
+ * @returns {string} The file's contents.
58
+ */
59
+ export function headers(pkg: string): string;
60
+ /**
61
+ * Write `_headers` and `_redirects` beside the rendered site.
62
+ *
63
+ * @param {object} options - Options.
64
+ * @param {string} options.pkg - The content package name, which is also the
65
+ * directory Hugo rendered into.
66
+ * @param {string} options.out - The directory that is deployed.
67
+ * @returns {{files: string[]}} The files written.
68
+ * @throws {Error} When no rendered site is there, which means the site build
69
+ * has not run and writing root files would publish a deployment with nothing
70
+ * under the prefix.
71
+ */
72
+ export function writeSiteRoot({ pkg, out }: {
73
+ pkg: string;
74
+ out: string;
75
+ }): {
76
+ files: string[];
77
+ };
78
+ /**
79
+ * The namespace the routing layer derives a package's origin in.
80
+ *
81
+ * `/<package>/` on the public host is proxied to
82
+ * `https://<package>.<suffix>/<package>/`, and {@link noindexHeaders} depends on
83
+ * that being a dedicated namespace.
84
+ *
85
+ * @type {string}
86
+ */
87
+ export const ORIGIN_SUFFIX: string;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The SVG with a scheme-aware fill rule, or unchanged where one cannot apply.
3
+ *
4
+ * Three files are returned as they are. One already carrying a
5
+ * `prefers-color-scheme` rule is themed — by this pass on an earlier run, or by
6
+ * its author — and re-theming it would stack rules. One with an inline `fill`
7
+ * cannot be themed at all, because an inline declaration beats a `<style>` rule
8
+ * and the result would be a half-recoloured icon, which is worse than an
9
+ * unthemed one. One with no `<svg>` element is not an SVG.
10
+ *
11
+ * @param {string} svg - The SVG source.
12
+ * @returns {string} The themed source, or the input unchanged.
13
+ */
14
+ export function injectAdaptiveFill(svg: string): string;
15
+ /**
16
+ * The staging hook: theme an SVG, and pass everything else through untouched.
17
+ *
18
+ * @param {string} sourcePath - The file being staged.
19
+ * @returns {string|null} The themed source, or `null` to stage the file as it
20
+ * is — which is what every non-SVG asset gets.
21
+ */
22
+ export function transform(sourcePath: string): string | null;