@heroiclands/package-build 25.6.3 → 25.7.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 (55) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +19 -1
  3. package/bin/content-commands.mjs +14 -70
  4. package/bin/package-build.mjs +25 -10
  5. package/ci/wip-workflows.test.mjs +252 -0
  6. package/content-config.mjs +40 -12
  7. package/docs/api.md +237 -48
  8. package/docs/commands.md +73 -36
  9. package/docs/configuration.md +92 -48
  10. package/docs/guides/release-and-deploy.md +26 -14
  11. package/docs/guides/site.md +29 -6
  12. package/engine/cdn-publish.mjs +164 -6
  13. package/engine/content-index.mjs +71 -19
  14. package/engine/content-spans.mjs +18 -1
  15. package/engine/content-styles.mjs +85 -0
  16. package/engine/derived-sections.mjs +4 -3
  17. package/engine/event-views.mjs +6 -4
  18. package/engine/helpers.mjs +33 -9
  19. package/engine/index.mjs +1 -0
  20. package/engine/note-cache.mjs +1107 -0
  21. package/engine/note-page.mjs +424 -0
  22. package/engine/pack-config.mjs +47 -4
  23. package/engine/pathnames.mjs +22 -7
  24. package/engine/prepared-only.mjs +32 -0
  25. package/engine/preview-index.mjs +55 -13
  26. package/engine/site-build.mjs +34 -448
  27. package/engine/site-config.mjs +33 -4
  28. package/engine/site-maps.mjs +16 -24
  29. package/engine/site-origins.mjs +23 -0
  30. package/engine/site-page.mjs +648 -0
  31. package/engine/site-render.mjs +142 -10
  32. package/engine/site-serve.mjs +1 -1
  33. package/engine/sql-tables.mjs +12 -3
  34. package/package.json +6 -1
  35. package/site-theme/static/css/style.css +14 -0
  36. package/types/content-config.d.mts +24 -4
  37. package/types/engine/cdn-publish.d.mts +22 -0
  38. package/types/engine/content-index.d.mts +36 -2
  39. package/types/engine/content-styles.d.mts +20 -0
  40. package/types/engine/derived-sections.d.mts +3 -3
  41. package/types/engine/event-views.d.mts +4 -3
  42. package/types/engine/helpers.d.mts +18 -1
  43. package/types/engine/index.d.mts +1 -0
  44. package/types/engine/note-cache.d.mts +149 -0
  45. package/types/engine/note-page.d.mts +92 -0
  46. package/types/engine/pack-config.d.mts +30 -0
  47. package/types/engine/prepared-only.d.mts +14 -0
  48. package/types/engine/preview-index.d.mts +35 -0
  49. package/types/engine/site-build.d.mts +5 -100
  50. package/types/engine/site-config.d.mts +3 -2
  51. package/types/engine/site-maps.d.mts +7 -11
  52. package/types/engine/site-origins.d.mts +9 -0
  53. package/types/engine/site-page.d.mts +261 -0
  54. package/types/engine/site-render.d.mts +20 -1
  55. package/types/engine/sql-tables.d.mts +8 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.7.0
4
+
5
+ **Rendering and sites**
6
+
7
+ - Preview a saved note with its website body, theme styles and findings, without building the whole site; unchanged inputs are cached for later previews.
8
+ - Content indexing and builds that use it are faster on large content trees.
9
+ - Root sites can generate matching menus and published navigation for the selected address, validating canonical roster data before replacing previous output.
10
+
11
+ **Builds and commands**
12
+
13
+ - An explicit root selects matching website and image addresses; without it, configured image hosts stay in use.
14
+ - CDN publication defaults to internal atlas storage; choose `--dest cloudflare` for the public bucket.
15
+
16
+ **Upgrading**
17
+
18
+ - Pass `--root heroiclands.org` to public builds; website builds now default to `internal.heroiclands.org`.
19
+
3
20
  ## 25.6.3
4
21
 
5
22
  **Rendering and sites**
package/README.md CHANGED
@@ -20,6 +20,13 @@ Build indexes, calendars, staged assets and packs together with `package-build c
20
20
 
21
21
  Run selected content operations together with `package-build content run index lint links compile`. The [shared content command](docs/commands.md#package-build-content-run-targets) captures one source tree for the selected checks and outputs; standalone commands remain available.
22
22
 
23
+ Render one note to the body HTML its site page carries with
24
+ `@heroiclands/package-build/note-page`: `renderNoteFile(path, { root })` reads a
25
+ saved note of a repository against its content tree and fetched dependency
26
+ indexes, and returns `{ html, styles, findings, cache }`. What it derives is
27
+ kept in `build/cache/note-page/` and reused while its inputs are unchanged, so a
28
+ second preview is fast; see [`./note-page`](docs/api.md#note-page).
29
+
23
30
  Language annotations keep authored spelling: `[Zaron]{lang=vyl}` stays Roman.
24
31
  Add `.nativefont` with an explicit `lang=` to use native script defaults;
25
32
  see [writing in invented scripts](docs/guides/writing-in-invented-scripts.md).
@@ -87,6 +94,7 @@ The documentation lives in the Markdown files in this repository. Start with [th
87
94
  | Invented languages and scripts | [Write in invented scripts](docs/guides/writing-in-invented-scripts.md) |
88
95
  | Encoding rules and the conversion core | [Encoding core](docs/encoding-core.md) |
89
96
  | Buffer-only preview and Gitea rendering | [Source preview](docs/source-preview.md) |
97
+ | One note as its site page's body | [`./note-page`](docs/api.md#note-page) |
90
98
  | Every CLI command | [Commands](docs/commands.md) |
91
99
  | Every build setting | [Configuration](docs/configuration.md) |
92
100
  | Programmatic exports | [API](docs/api.md) |
@@ -97,7 +105,7 @@ Place notes may declare a governing affiliation with `data.government` (`Address
97
105
 
98
106
  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).
99
107
 
100
- `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).
108
+ `package-build cdn publish [--dest internal|cloudflare] --set <file> --name <display name>` manually uploads every icon and image, shipped or left out, and writes a named set listing the left-out pictures. The default publishes to atlas at `https://cdn.internal.heroiclands.org/<contentPackage>/` using SSH and rsync. `--dest cloudflare` uses the existing R2 bucket at `site.assets` and its `CDN_ENDPOINT`, `CDN_BUCKET`, `CDN_ACCESS_KEY_ID` and `CDN_SECRET_ACCESS_KEY` environment settings. Site builds never publish pictures. See [publish pictures to the CDN](docs/guides/release-and-deploy.md#publish-pictures-to-the-cdn).
101
109
 
102
110
  Leading captions, fenced divs, inline spans, and verse quotations are described in [links and markup](docs/authoring/links-and-markup.md).
103
111
 
@@ -107,3 +115,13 @@ installed browser with `package-build e2e fast -- --browser electron` (also
107
115
  available for `run` and `open`). See [browser selection and cache settings](docs/guides/e2e.md#choose-a-browser).
108
116
 
109
117
  The package is GPL-3.0-or-later for code and CC-BY-SA-4.0 for original content. See [LICENSE.md](LICENSE.md).
118
+
119
+ ## Pull request checks
120
+
121
+ A pull request whose title starts with `WIP:` runs the light checks. An existing
122
+ required check runs a short guard and fails, blocking merges while the expensive
123
+ steps stay skipped. Remove the prefix with `earl pr ready`; it starts fresh
124
+ required checks before scheduling a merge. Changing the title by hand does not
125
+ start those checks. Main, release and manual runs keep their existing checks.
126
+
127
+ Check the workflow gating locally with `node --test ci/wip-workflows.test.mjs`.
@@ -133,6 +133,11 @@ import {
133
133
  siteBuildFailed,
134
134
  formatUnaddressableFinding as formatUnaddressable,
135
135
  } from "../engine/site-build.mjs";
136
+ import {
137
+ addressDiagnostic,
138
+ frontmatterLinkDiagnostic,
139
+ pageDiagnostics,
140
+ } from "../engine/site-page.mjs";
136
141
  import { buildPackageSite, siteDirectory } from "../engine/site-render.mjs";
137
142
  import { serveSite } from "../engine/site-serve.mjs";
138
143
  import { writeSiteRoot } from "../engine/site-root.mjs";
@@ -2172,23 +2177,8 @@ function reportSiteBuild(result) {
2172
2177
  // or two competing for it, has nothing to say about its pages
2173
2178
  // yet.
2174
2179
  for (const f of gates.homepages) emitDiagnostic(f);
2175
- for (const f of gates.frontmatterLinks) {
2176
- emitDiagnostic({
2177
- file: f.file,
2178
- severity: "error",
2179
- message:
2180
- `wikilink ${f.link} authored in frontmatter at ` +
2181
- `${f.path} — frontmatter is data, is copied to the ` +
2182
- `page verbatim, and reaches the reader as brackets`,
2183
- });
2184
- }
2185
- for (const f of gates.addressErrors) {
2186
- emitDiagnostic({
2187
- file: f.file,
2188
- severity: "error",
2189
- message: `cannot derive a URL: ${f.reason}`,
2190
- });
2191
- }
2180
+ for (const f of gates.frontmatterLinks) emitDiagnostic(frontmatterLinkDiagnostic(f));
2181
+ for (const f of gates.addressErrors) emitDiagnostic(addressDiagnostic(f));
2192
2182
  reportSiteFindings(gates.sections ?? []);
2193
2183
  for (const s of gates.staleManifests) {
2194
2184
  emitDiagnostic({
@@ -2208,58 +2198,7 @@ function reportSiteBuild(result) {
2208
2198
  // would make a single bad table hide every other problem in the tree.
2209
2199
  // Each is `file:line:column: error: message`, path first — the shape
2210
2200
  // the pack build reports the same findings in.
2211
- for (const e of result.tableErrors) {
2212
- emitDiagnostic({
2213
- file: e.source,
2214
- line: typeof e.line === "number" ? e.line + 1 : undefined,
2215
- column: e.column,
2216
- severity: "error",
2217
- message: e.reason,
2218
- });
2219
- }
2220
- for (const e of result.expressionErrors) {
2221
- emitDiagnostic(e);
2222
- }
2223
- for (const e of result.secretErrors) {
2224
- emitDiagnostic({ ...e, severity: "error" });
2225
- }
2226
- for (const e of result.captionErrors) {
2227
- emitDiagnostic({ ...e, severity: "error" });
2228
- }
2229
- for (const e of result.headingErrors) {
2230
- emitDiagnostic({ ...e, severity: "error" });
2231
- }
2232
- for (const e of result.footnoteErrors) {
2233
- emitDiagnostic({ ...e, severity: "error" });
2234
- }
2235
- for (const e of result.embedErrors) {
2236
- emitDiagnostic({ ...e, severity: "error" });
2237
- }
2238
- // Worded by the shared table the pack build words the same finding
2239
- // with, and located by searching the note for the link.
2240
- for (const e of result.wikiErrors) {
2241
- emitDiagnostic({
2242
- file: e.file,
2243
- ...positionOfLiteral(readRawNote(e.file), e.link, e.occurrence),
2244
- severity: "error",
2245
- // An embed's directive speaks the image vocabulary
2246
- // rather than the link one, and carries its own
2247
- // sentence; everything else is worded by the shared
2248
- // table.
2249
- message: e.message ?? linkFindingMessage(e),
2250
- });
2251
- }
2252
- // An image whose pathname the site cannot resolve, located the
2253
- // way a wikilink finding is: by searching the note for the
2254
- // literal the resolver was handed.
2255
- for (const e of result.imageErrors) {
2256
- emitDiagnostic({
2257
- file: e.file,
2258
- ...positionOfLiteral(readRawNote(e.file), e.src, e.occurrence),
2259
- severity: "error",
2260
- message: e.message,
2261
- });
2262
- }
2201
+ for (const d of pageDiagnostics(result, readRawNote)) emitDiagnostic(d);
2263
2202
  if (siteBuildFailed(result)) return false;
2264
2203
 
2265
2204
  // Relation warnings do not fail a site build.
@@ -2307,11 +2246,11 @@ function withSiteBuildConfig(command) {
2307
2246
  return {
2308
2247
  ...command,
2309
2248
  handler: async (argv) => {
2310
- if (argv.baseUrl === undefined && argv.assets === undefined) return handler(argv);
2311
2249
  try {
2312
2250
  const authored = argv[CONTENT_CONFIG] ?? loadPackConfig();
2313
2251
  checkHomepage(authored.homepage, authored.contentPackage, authored.site.base);
2314
2252
  const config = siteBuildConfig(authored, {
2253
+ root: argv.root,
2315
2254
  baseUrl: argv.baseUrl,
2316
2255
  assets: argv.assets,
2317
2256
  });
@@ -2377,6 +2316,11 @@ function siteBuildCommand() {
2377
2316
  type: "string",
2378
2317
  describe: `The directory that is deployed (default: ${DEPLOY_ROOT})`,
2379
2318
  })
2319
+ .option("root", {
2320
+ type: "string",
2321
+ requiresArg: true,
2322
+ describe: "Domain for www and CDN origins (default: internal.heroiclands.org)",
2323
+ })
2380
2324
  .option("base-url", {
2381
2325
  type: "string",
2382
2326
  requiresArg: true,
@@ -61,7 +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
+ * npx package-build cdn publish [--dest internal|cloudflare] --set <file> --name <display name> [--dry-run]
65
65
  * npx package-build container <stage> <action>
66
66
  * npx package-build e2e <seed|run|open|fast|sweep>
67
67
  *
@@ -96,6 +96,8 @@ import { loadPackConfig, packConfigPath } from "../engine/pack-config.mjs";
96
96
  import { cleanBuildArtifacts, resetStage } from "../stage.mjs";
97
97
  import {
98
98
  CDN_ENV,
99
+ INTERNAL_CDN_HOST,
100
+ internalStore,
99
101
  cdnCredentials,
100
102
  noteArtFindings,
101
103
  noteArtReferences,
@@ -1417,11 +1419,9 @@ function deployCommand() {
1417
1419
  * `cdn publish` — upload every picture the package has to the CDN, and the art
1418
1420
  * the package leaves out as a named asset set.
1419
1421
  *
1420
- * The one writer to `<site.assets>/<package>/`. It is run by hand or by a
1421
- * dispatched workflow, never by a build or a release. The store is named by
1422
- * the `CDN_*` variables in `CDN_ENV`, read from the environment or
1423
- * `.env.local`; a dry run with none of them set compares against the public
1424
- * CDN instead.
1422
+ * Run manually, separate from builds and releases. Internal uses rsync to atlas
1423
+ * and public HTTPS reads. Cloudflare uses the existing `CDN_*` R2 credentials;
1424
+ * without those credentials its dry run compares against `site.assets`.
1425
1425
  *
1426
1426
  * @returns {object} The yargs command module.
1427
1427
  */
@@ -1431,6 +1431,12 @@ function cdnPublishCommand() {
1431
1431
  describe: "Upload every picture to the CDN and write a named asset set",
1432
1432
  builder: (y) =>
1433
1433
  y
1434
+ .option("dest", {
1435
+ type: "string",
1436
+ choices: ["internal", "cloudflare"],
1437
+ default: "internal",
1438
+ describe: "CDN destination: atlas or Cloudflare R2",
1439
+ })
1434
1440
  .option("set", {
1435
1441
  type: "string",
1436
1442
  demandOption: true,
@@ -1455,15 +1461,18 @@ function cdnPublishCommand() {
1455
1461
  if (setProblem) die(setProblem);
1456
1462
  const name = String(args.name ?? "").trim();
1457
1463
  if (!name) die("`--name` is blank; a set needs a display name");
1458
- const host = String(packConfig.site?.assets ?? "").trim();
1464
+ const host =
1465
+ args.dest === "internal" ?
1466
+ INTERNAL_CDN_HOST
1467
+ : String(packConfig.site?.assets ?? "").trim();
1459
1468
  if (!host) {
1460
1469
  die(
1461
1470
  "`site.assets` is not declared, and it is the CDN root every picture " +
1462
1471
  "is published under",
1463
1472
  );
1464
1473
  }
1465
- const credentials = cdnCredentials(process.env);
1466
- if (!credentials && !args.dryRun) {
1474
+ const credentials = args.dest === "cloudflare" ? cdnCredentials(process.env) : null;
1475
+ if (args.dest === "cloudflare" && !credentials && !args.dryRun) {
1467
1476
  die(
1468
1477
  `no CDN store is configured: set ${Object.values(CDN_ENV).join(", ")} ` +
1469
1478
  "in the environment or `.env.local`",
@@ -1501,7 +1510,13 @@ function cdnPublishCommand() {
1501
1510
  records: plan.records,
1502
1511
  });
1503
1512
 
1504
- const store = credentials ? s3Store(credentials) : publicStore(host);
1513
+ const store =
1514
+ args.dest === "internal" ?
1515
+ args.dryRun ?
1516
+ publicStore(host)
1517
+ : internalStore({ destination: process.env.CDN_INTERNAL_DESTINATION })
1518
+ : credentials ? s3Store(credentials)
1519
+ : publicStore(host);
1505
1520
  console.log(
1506
1521
  `${args.dryRun ? "Dry run: comparing" : "Publishing"} ${plan.uploads.length} ` +
1507
1522
  `pictures against ${store.describe}.`,
@@ -0,0 +1,252 @@
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
+ import assert from "node:assert/strict";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { createRequire } from "node:module";
17
+ import { spawnSync } from "node:child_process";
18
+ import { test } from "node:test";
19
+
20
+ const yaml = createRequire(import.meta.url)("yaml");
21
+ const spec = {
22
+ file: "build.yml",
23
+ gated: ["test-build"],
24
+ light: "light",
25
+ runs: [
26
+ "npm run format:check",
27
+ "npm run lint:markdown",
28
+ "npm run lint:docs-links",
29
+ "npm run lint:content-format",
30
+ "node bin/package-build.mjs changelog check",
31
+ ],
32
+ dependency: "light",
33
+ test: "ci/wip-workflows.test.mjs",
34
+ required: [
35
+ "Build and Test / Build & Test (pull_request)",
36
+ "No Attribution / No AI attribution (pull_request)",
37
+ ],
38
+ lightActions: [
39
+ "HeroicLands/.github/actions/githooks",
40
+ "HeroicLands/.github/actions/todos",
41
+ "HeroicLands/.github/actions/no-attribution",
42
+ ],
43
+ };
44
+ const workflows = Object.fromEntries(
45
+ fs
46
+ .readdirSync(".gitea/workflows")
47
+ .filter((name) => name.endsWith(".yml"))
48
+ .map((name) => [
49
+ name,
50
+ yaml.parse(fs.readFileSync(path.join(".gitea/workflows", name), "utf8")),
51
+ ]),
52
+ );
53
+ const workflow = workflows[spec.file];
54
+ const jobs = workflow.jobs;
55
+
56
+ // Evaluate the job and step condition surface used here, including Gitea's
57
+ // implicit success() and case-insensitive startsWith().
58
+ function condition(
59
+ expression,
60
+ {
61
+ title,
62
+ event = "pull_request",
63
+ results = {},
64
+ steps = {},
65
+ success = true,
66
+ cancelled = false,
67
+ } = {},
68
+ ) {
69
+ const source = String(expression ?? "success()").replace(/^\$\{\{\s*|\s*\}\}$/g, "");
70
+ if (!/\b(?:always|cancelled|success|failure)\(/.test(source) && !success) return false;
71
+ const needs = Object.fromEntries(
72
+ Object.entries(results).map(([id, result]) => [id, { result, outputs: { run: "true" } }]),
73
+ );
74
+ return Function(
75
+ "github",
76
+ "needs",
77
+ "steps",
78
+ "startsWith",
79
+ "cancelled",
80
+ "success",
81
+ "failure",
82
+ "always",
83
+ `return (${source});`,
84
+ )(
85
+ { event_name: event, event: { pull_request: { title } } },
86
+ needs,
87
+ steps,
88
+ (value, prefix) =>
89
+ String(value ?? "")
90
+ .toLowerCase()
91
+ .startsWith(prefix.toLowerCase()),
92
+ () => cancelled,
93
+ () => success,
94
+ () => !success && !cancelled,
95
+ () => true,
96
+ );
97
+ }
98
+ function enabled(job, title, event = "pull_request", results = {}, cancelled = false) {
99
+ const dependencies = [job.needs ?? []].flat();
100
+ const all = Object.fromEntries(dependencies.map((id) => [id, results[id] ?? "success"]));
101
+ return condition(job.if, {
102
+ title,
103
+ event,
104
+ results: all,
105
+ cancelled,
106
+ success: Object.values(all).every((result) => result === "success"),
107
+ });
108
+ }
109
+ function shell(run, env = {}) {
110
+ const localEnv = { ...process.env };
111
+ delete localEnv.PR_TITLE;
112
+ delete localEnv.LIGHT_RESULT;
113
+ return spawnSync("bash", ["-e", "-o", "pipefail", "-c", run], { env: { ...localEnv, ...env } })
114
+ .status;
115
+ }
116
+ const requiredJobs = Object.entries(jobs).filter(([id, job]) =>
117
+ spec.required.includes(`${workflow.name} / ${job.name ?? id} (pull_request)`),
118
+ );
119
+
120
+ test("a required merge gate runs and fails on WIP instead of satisfying branch protection", () => {
121
+ const gates = requiredJobs.filter(([, job]) => enabled(job, "WIP: work"));
122
+ const blockers = gates.filter(
123
+ ([, job]) => job.steps?.[0]?.name === "Require a ready pull request",
124
+ );
125
+ assert.ok(
126
+ blockers.length,
127
+ "all required checks were satisfied or skipped without a WIP blocker",
128
+ );
129
+ for (const [, job] of blockers) {
130
+ assert.doesNotMatch(job.if ?? "", /startsWith/);
131
+ const guard = job.steps[0];
132
+ assert.notEqual(job["continue-on-error"], true);
133
+ assert.notEqual(guard["continue-on-error"], true);
134
+ assert.equal(condition(guard.if, { title: "WIP: work" }), true);
135
+ for (const result of ["success", "failure", "cancelled", "skipped"]) {
136
+ const results = Object.fromEntries([job.needs ?? []].flat().map((id) => [id, result]));
137
+ assert.equal(enabled(job, "WIP: work", "pull_request", results), true);
138
+ }
139
+ assert.equal(guard.env.PR_TITLE, "${{ github.event.pull_request.title }}");
140
+ assert.ok(
141
+ !guard.run.includes("${{ github.event.pull_request.title }}"),
142
+ "titles must be passed through env",
143
+ );
144
+ assert.equal(shell(guard.run), 0, "raw local replay");
145
+ for (const title of ["WIP: work", "wip: work", "WIP: $(exit 7)"])
146
+ assert.equal(shell(guard.run, { PR_TITLE: title }), 1);
147
+ assert.equal(shell(guard.run, { PR_TITLE: "Ready $(exit 7)" }), 0);
148
+ }
149
+ });
150
+
151
+ test("every expensive job or step skips WIP while ready and non-PR work remains applicable", () => {
152
+ const heavy = [
153
+ ...spec.gated.map((id) => jobs[id]),
154
+ ...(spec.e2e ? [workflows["e2e-full.yml"].jobs["full-e2e"]] : []),
155
+ ];
156
+ // Also derive the compile and reusable-e2e surface from the workflows.
157
+ for (const current of Object.values(workflows)) {
158
+ if (!Object.hasOwn(current.on ?? {}, "pull_request")) continue;
159
+ for (const job of Object.values(current.jobs ?? {}))
160
+ if (
161
+ ["build", "ubuntu-vm"].includes(job["runs-on"]) ||
162
+ /\/workflows\/e2e-full\.yml@/.test(job.uses ?? "")
163
+ )
164
+ if (!heavy.includes(job)) heavy.push(job);
165
+ }
166
+ for (const job of heavy) {
167
+ assert.ok(job);
168
+ assert.equal(enabled(job, "Ready work"), true, job.name);
169
+ for (const event of ["push", "schedule", "workflow_dispatch"])
170
+ assert.equal(enabled(job, undefined, event), true, `${job.name}: ${event}`);
171
+ if (!enabled(job, "WIP: work")) continue;
172
+ assert.equal(job.steps[0].name, "Require a ready pull request");
173
+ const outcomes = Object.fromEntries(
174
+ job.steps.filter((step) => step.id).map((step) => [step.id, { outcome: "success" }]),
175
+ );
176
+ for (const step of job.steps.slice(1)) {
177
+ assert.match(step.if ?? "", /startsWith.*WIP:/, step.name ?? step.uses);
178
+ assert.equal(
179
+ condition(step.if, { title: "WIP: work", steps: outcomes }),
180
+ false,
181
+ step.name ?? step.uses,
182
+ );
183
+ }
184
+ }
185
+ });
186
+ test("light checks, attribution and changeset review remain independent on WIP", () => {
187
+ const light = Object.values(workflows)
188
+ .flatMap((w) => Object.values(w.jobs ?? {}))
189
+ .filter((job) => !String(job.if ?? "").includes("startsWith"));
190
+ const steps = light.flatMap((job) => job.steps ?? []);
191
+ for (const run of spec.runs)
192
+ assert.ok(
193
+ steps.some((step) => step.run === run || step.run?.includes(`${run}\n`)),
194
+ `missing light command: ${run}`,
195
+ );
196
+ for (const action of spec.lightActions)
197
+ assert.ok(
198
+ steps.some((step) => step.uses?.split("@")[0] === action),
199
+ `missing light action: ${action}`,
200
+ );
201
+ assert.equal(enabled(jobs[spec.light], "WIP: work"), true);
202
+ assert.ok(![jobs[spec.light].needs ?? []].flat().some((id) => spec.gated.includes(id)));
203
+ for (const file of [spec.file, ...(spec.e2e ? ["e2e-full.yml"] : [])])
204
+ assert.ok(!workflows[file].on.pull_request?.types?.includes("edited"), file);
205
+ });
206
+
207
+ test("protected status context names retain their exact spelling", () => {
208
+ const names = Object.values(workflows).flatMap((w) =>
209
+ Object.entries(w.jobs ?? {}).map(
210
+ ([id, job]) => `${w.name} / ${job.name ?? id} (pull_request)`,
211
+ ),
212
+ );
213
+ for (const required of spec.required) assert.ok(names.includes(required), required);
214
+ });
215
+
216
+ if (spec.dependency)
217
+ test("light dependency results fail closed without breaking raw local replay", () => {
218
+ for (const id of spec.gated) {
219
+ const job = jobs[id];
220
+ assert.ok([job.needs ?? []].flat().includes(spec.dependency));
221
+ const check = job.steps.find((step) => step.name === "Require light checks");
222
+ assert.ok(check, id);
223
+ assert.equal(shell(check.run), 0);
224
+ assert.equal(check.env?.LIGHT_RESULT, `\${{ needs.${spec.dependency}.result }}`);
225
+ for (const result of ["success", "failure", "cancelled", "skipped", ""]) {
226
+ assert.equal(
227
+ enabled(job, "Ready", "pull_request", { [spec.dependency]: result }),
228
+ true,
229
+ );
230
+ assert.equal(
231
+ shell(check.run, { LIGHT_RESULT: result }),
232
+ result === "success" ? 0 : 1,
233
+ );
234
+ const outcomes = {
235
+ ready: { outcome: "success" },
236
+ light_checks: { outcome: result === "success" ? "success" : "failure" },
237
+ };
238
+ for (const step of job.steps.filter(
239
+ (entry) =>
240
+ !["Require a ready pull request", "Require light checks"].includes(
241
+ entry.name,
242
+ ),
243
+ ))
244
+ assert.equal(
245
+ condition(step.if, { title: "Ready", steps: outcomes }),
246
+ result === "success",
247
+ step.name ?? step.uses,
248
+ );
249
+ }
250
+ assert.equal(enabled(job, "Ready", "pull_request", {}, true), false);
251
+ }
252
+ });
@@ -234,19 +234,34 @@ export function siteBase(config) {
234
234
  *
235
235
  * @param {ContentBuildConfig} config - The resolved authored configuration.
236
236
  * @param {object} options - Runtime options.
237
- * @param {string} [options.baseUrl] - Absolute HTTP(S) site URL ending in `/`.
238
- * @param {string} [options.assets] - Absolute HTTP(S) asset root.
239
- * @returns {ContentBuildConfig} The effective configuration, frozen.
240
- */
241
- export function siteBuildConfig(config, { baseUrl, assets }) {
242
- if (baseUrl === undefined && assets === undefined) return config;
243
- const homepage = baseUrl === undefined ? config.homepage : buildUrl(baseUrl, "--base-url").href;
244
- const base =
245
- baseUrl === undefined ? config.site.base : normalizeSiteBase(new URL(homepage).pathname);
237
+ * @param {string} [options.root] - Domain from which www and CDN origins derive.
238
+ * Defaults to internal.heroiclands.org; explicit root replaces configured assets.
239
+ * @param {string} [options.baseUrl] - Absolute HTTP(S) site URL ending in `/`;
240
+ * overrides the root-derived site URL.
241
+ * @param {string} [options.assets] - Absolute HTTP(S) asset root; overrides both
242
+ * root-derived and configured assets.
243
+ * @returns {ContentBuildConfig} The effective configuration, frozen. Its homepage
244
+ * and site.assets are the selected inputs for downstream site integrations.
245
+ */
246
+ export function siteBuildConfig(config, { root, baseUrl, assets } = {}) {
247
+ const domain = root ?? "internal.heroiclands.org";
248
+ if (
249
+ typeof domain !== "string" ||
250
+ domain.length > 253 ||
251
+ !domain.includes(".") ||
252
+ !domain.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))
253
+ ) {
254
+ throw new TypeError("--root must be a domain without a scheme, path, port or credentials");
255
+ }
256
+ const homepage =
257
+ baseUrl === undefined ?
258
+ `https://www.${domain.toLowerCase()}${new URL(config.homepage).pathname}`
259
+ : buildUrl(baseUrl, "--base-url").href;
260
+ const base = normalizeSiteBase(new URL(homepage).pathname);
246
261
  const assetRoot =
247
- assets === undefined ?
248
- config.site.assets
249
- : normalizeSiteAssets(buildUrl(assets, "--assets").href);
262
+ assets !== undefined ? normalizeSiteAssets(buildUrl(assets, "--assets").href)
263
+ : root !== undefined ? `https://cdn.${domain.toLowerCase()}`
264
+ : config.site.assets || `https://cdn.${domain.toLowerCase()}`;
250
265
  checkSiteSitemaps(config.site.sitemaps, base, config.contentPackage);
251
266
  return Object.freeze({
252
267
  ...config,
@@ -697,6 +712,8 @@ export function publishesContentPages(config) {
697
712
  * `pack:` frontmatter, and one pack of
698
713
  * the type is marked `default: true` to
699
714
  * receive the notes that name none.
715
+ * @property {Record<string, any> & {inputs?: string}} [site] Site settings;
716
+ * inputs names the optional local input provider.
700
717
  * @property {PathsInput} [paths] Layout overrides. See {@link DEFAULT_PATHS}.
701
718
  * @property {string[]} [skipDirectories] Directory names the content walk ignores
702
719
  * wherever they appear (e.g. Obsidian's
@@ -752,6 +769,8 @@ export function publishesContentPages(config) {
752
769
  * which Foundry serves no files for. See
753
770
  * {@link module:engine/helpers.resolveImg},
754
771
  * the one reader of it.
772
+ * @property {Readonly<Record<string, any> & {inputs: string}>} site Frozen site settings;
773
+ * inputs is the local provider path or an empty string.
755
774
  * @property {Readonly<ResolvedPaths>} paths
756
775
  * @property {Readonly<StatsSpec>|null} stats `null` for a `documentation`
757
776
  * package, which stamps no `_stats`.
@@ -857,6 +876,7 @@ const SITE_KEYS = [
857
876
  "tags",
858
877
  "sitemaps",
859
878
  "mergeIndexes",
879
+ "inputs",
860
880
  ];
861
881
  const SITE_NOTFOUND_KEYS = ["tagline", "sitenoun", "heroimage", "links"];
862
882
  const SITE_NOTFOUND_LINK_KEYS = ["title", "url", "text"];
@@ -2011,6 +2031,7 @@ function normalizeSite(value, contentPackage) {
2011
2031
  tags: false,
2012
2032
  sitemaps: Object.freeze([]),
2013
2033
  mergeIndexes: Object.freeze([]),
2034
+ inputs: "",
2014
2035
  });
2015
2036
  if (value === undefined) return empty;
2016
2037
  if (!isPlainObject(value)) fail("site", "must be a mapping");
@@ -2038,6 +2059,12 @@ function normalizeSite(value, contentPackage) {
2038
2059
  );
2039
2060
  }
2040
2061
 
2062
+ const inputs =
2063
+ input.inputs === undefined ? "" : requireNonEmptyString(input.inputs, "site.inputs");
2064
+ if (inputs && !inputs.split("/").every((segment) => /^[a-z0-9][a-z0-9._-]*$/i.test(segment))) {
2065
+ fail("site.inputs", "must be a plain relative module path without dot segments");
2066
+ }
2067
+
2041
2068
  return Object.freeze({
2042
2069
  base,
2043
2070
  title: input.title === undefined ? "" : requireNonEmptyString(input.title, "site.title"),
@@ -2060,6 +2087,7 @@ function normalizeSite(value, contentPackage) {
2060
2087
  tags: optionalBoolean(input.tags, "site.tags", false),
2061
2088
  sitemaps,
2062
2089
  mergeIndexes,
2090
+ inputs,
2063
2091
  });
2064
2092
  }
2065
2093