@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.
- package/CHANGELOG.md +17 -0
- package/README.md +19 -1
- package/bin/content-commands.mjs +14 -70
- package/bin/package-build.mjs +25 -10
- package/ci/wip-workflows.test.mjs +252 -0
- package/content-config.mjs +40 -12
- package/docs/api.md +237 -48
- package/docs/commands.md +73 -36
- package/docs/configuration.md +92 -48
- package/docs/guides/release-and-deploy.md +26 -14
- package/docs/guides/site.md +29 -6
- package/engine/cdn-publish.mjs +164 -6
- package/engine/content-index.mjs +71 -19
- package/engine/content-spans.mjs +18 -1
- package/engine/content-styles.mjs +85 -0
- package/engine/derived-sections.mjs +4 -3
- package/engine/event-views.mjs +6 -4
- package/engine/helpers.mjs +33 -9
- package/engine/index.mjs +1 -0
- package/engine/note-cache.mjs +1107 -0
- package/engine/note-page.mjs +424 -0
- package/engine/pack-config.mjs +47 -4
- package/engine/pathnames.mjs +22 -7
- package/engine/prepared-only.mjs +32 -0
- package/engine/preview-index.mjs +55 -13
- package/engine/site-build.mjs +34 -448
- package/engine/site-config.mjs +33 -4
- package/engine/site-maps.mjs +16 -24
- package/engine/site-origins.mjs +23 -0
- package/engine/site-page.mjs +648 -0
- package/engine/site-render.mjs +142 -10
- package/engine/site-serve.mjs +1 -1
- package/engine/sql-tables.mjs +12 -3
- package/package.json +6 -1
- package/site-theme/static/css/style.css +14 -0
- package/types/content-config.d.mts +24 -4
- package/types/engine/cdn-publish.d.mts +22 -0
- package/types/engine/content-index.d.mts +36 -2
- package/types/engine/content-styles.d.mts +20 -0
- package/types/engine/derived-sections.d.mts +3 -3
- package/types/engine/event-views.d.mts +4 -3
- package/types/engine/helpers.d.mts +18 -1
- package/types/engine/index.d.mts +1 -0
- package/types/engine/note-cache.d.mts +149 -0
- package/types/engine/note-page.d.mts +92 -0
- package/types/engine/pack-config.d.mts +30 -0
- package/types/engine/prepared-only.d.mts +14 -0
- package/types/engine/preview-index.d.mts +35 -0
- package/types/engine/site-build.d.mts +5 -100
- package/types/engine/site-config.d.mts +3 -2
- package/types/engine/site-maps.d.mts +7 -11
- package/types/engine/site-origins.d.mts +9 -0
- package/types/engine/site-page.d.mts +261 -0
- package/types/engine/site-render.d.mts +20 -1
- 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
|
|
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`.
|
package/bin/content-commands.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
package/bin/package-build.mjs
CHANGED
|
@@ -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
|
-
*
|
|
1421
|
-
*
|
|
1422
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
+
});
|
package/content-config.mjs
CHANGED
|
@@ -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.
|
|
238
|
-
*
|
|
239
|
-
* @
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
:
|
|
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
|
|