vagejs 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +244 -2
- package/dist/audit/a11y.d.ts +13 -0
- package/dist/audit/a11y.js +33 -0
- package/dist/audit/audit.d.ts +31 -0
- package/dist/audit/audit.js +143 -0
- package/dist/audit/budgets.d.ts +22 -0
- package/dist/audit/budgets.js +106 -0
- package/dist/audit/lighthouse.d.ts +3 -0
- package/dist/audit/lighthouse.js +48 -0
- package/dist/audit/remote.d.ts +65 -0
- package/dist/audit/remote.js +123 -0
- package/dist/audit/vitals.d.ts +28 -0
- package/dist/audit/vitals.js +18 -0
- package/dist/checks/check.d.ts +31 -0
- package/dist/checks/check.js +434 -0
- package/dist/checks/integrity.d.ts +36 -0
- package/dist/checks/integrity.js +145 -0
- package/dist/checks/links.d.ts +18 -0
- package/dist/checks/links.js +47 -0
- package/dist/checks/verify.d.ts +35 -0
- package/dist/checks/verify.js +167 -0
- package/dist/cli/create.d.ts +10 -0
- package/dist/cli/create.js +39 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +710 -0
- package/dist/cli/info.d.ts +82 -0
- package/dist/cli/info.js +100 -0
- package/dist/content/images.d.ts +23 -0
- package/dist/content/images.js +96 -0
- package/dist/content/markdown.d.ts +28 -0
- package/dist/content/markdown.js +56 -0
- package/dist/content/sanitize.d.ts +16 -0
- package/dist/content/sanitize.js +57 -0
- package/dist/core/build.d.ts +25 -0
- package/dist/core/build.js +54 -0
- package/dist/core/clone.d.ts +6 -0
- package/dist/core/clone.js +16 -0
- package/dist/core/config.d.ts +180 -0
- package/dist/core/config.js +260 -0
- package/dist/core/errors.d.ts +7 -0
- package/dist/core/errors.js +12 -0
- package/dist/core/html.d.ts +24 -0
- package/dist/core/html.js +42 -0
- package/dist/core/loader.d.ts +10 -0
- package/dist/core/loader.js +16 -0
- package/dist/core/pages.d.ts +40 -0
- package/dist/core/pages.js +93 -0
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +14 -0
- package/dist/core/render.d.ts +53 -0
- package/dist/core/render.js +104 -0
- package/dist/core/routes.d.ts +24 -0
- package/dist/core/routes.js +70 -0
- package/dist/core/site.d.ts +42 -0
- package/dist/core/site.js +182 -0
- package/dist/core/validate.d.ts +12 -0
- package/dist/core/validate.js +36 -0
- package/dist/deploy/cloudflare-pages.d.ts +20 -0
- package/dist/deploy/cloudflare-pages.js +42 -0
- package/dist/deploy/cloudflare-workers.d.ts +59 -0
- package/dist/deploy/cloudflare-workers.js +223 -0
- package/dist/deploy/git.d.ts +20 -0
- package/dist/deploy/git.js +55 -0
- package/dist/deploy/github-pages.d.ts +33 -0
- package/dist/deploy/github-pages.js +99 -0
- package/dist/deploy/headers.d.ts +11 -0
- package/dist/deploy/headers.js +103 -0
- package/dist/deploy/publish.d.ts +45 -0
- package/dist/deploy/publish.js +148 -0
- package/dist/deploy/redirects.d.ts +7 -0
- package/dist/deploy/redirects.js +18 -0
- package/dist/deploy/secrets.d.ts +6 -0
- package/dist/deploy/secrets.js +66 -0
- package/dist/dev/guard.d.ts +9 -0
- package/dist/dev/guard.js +57 -0
- package/dist/dev/listen.d.ts +6 -0
- package/dist/dev/listen.js +13 -0
- package/dist/dev/preview.d.ts +6 -0
- package/dist/dev/preview.js +72 -0
- package/dist/dev/server.d.ts +8 -0
- package/dist/dev/server.js +104 -0
- package/dist/discovery/crawlers.d.ts +13 -0
- package/dist/discovery/crawlers.js +2 -0
- package/dist/discovery/llms.d.ts +12 -0
- package/dist/discovery/llms.js +19 -0
- package/dist/discovery/robots.d.ts +8 -0
- package/dist/discovery/robots.js +40 -0
- package/dist/discovery/sitemap.d.ts +8 -0
- package/dist/discovery/sitemap.js +11 -0
- package/dist/guidelines/monitor.d.ts +55 -0
- package/dist/guidelines/monitor.js +126 -0
- package/dist/guidelines/pack.d.ts +41 -0
- package/dist/guidelines/pack.js +13 -0
- package/dist/guidelines/rule-pack.json +611 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/integrations/api-keys.d.ts +26 -0
- package/dist/integrations/api-keys.js +65 -0
- package/dist/integrations/ga4-data.d.ts +13 -0
- package/dist/integrations/ga4-data.js +39 -0
- package/dist/integrations/google-auth.d.ts +47 -0
- package/dist/integrations/google-auth.js +157 -0
- package/dist/integrations/http.d.ts +15 -0
- package/dist/integrations/http.js +40 -0
- package/dist/integrations/indexnow.d.ts +18 -0
- package/dist/integrations/indexnow.js +38 -0
- package/dist/integrations/search-console.d.ts +25 -0
- package/dist/integrations/search-console.js +39 -0
- package/dist/integrations/tags.d.ts +22 -0
- package/dist/integrations/tags.js +67 -0
- package/dist/seo/head.d.ts +20 -0
- package/dist/seo/head.js +85 -0
- package/dist/seo/jsonld.d.ts +35 -0
- package/dist/seo/jsonld.js +31 -0
- package/dist/seo/urls.d.ts +3 -0
- package/dist/seo/urls.js +10 -0
- package/package.json +81 -4
- package/templates/starter/.env.example +12 -0
- package/templates/starter/.github/dependabot.yml +11 -0
- package/templates/starter/.github/workflows/ci.yml +30 -0
- package/templates/starter/.github/workflows/verify.yml +32 -0
- package/templates/starter/AGENTS.md +166 -0
- package/templates/starter/CLAUDE.md +3 -0
- package/templates/starter/_gitignore +8 -0
- package/templates/starter/links.lock.json +4 -0
- package/templates/starter/package.json +16 -0
- package/templates/starter/public/apple-touch-icon.png +0 -0
- package/templates/starter/public/favicon.ico +0 -0
- package/templates/starter/public/favicon.svg +1 -0
- package/templates/starter/src/layouts/blog.ts +13 -0
- package/templates/starter/src/layouts/default.ts +38 -0
- package/templates/starter/src/pages/404.md +7 -0
- package/templates/starter/src/pages/about.md +8 -0
- package/templates/starter/src/pages/blog/first-post.md +10 -0
- package/templates/starter/src/pages/blog/index.md +7 -0
- package/templates/starter/src/pages/index.md +8 -0
- package/templates/starter/vage.config.ts +38 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { redact } from "../integrations/http.js";
|
|
2
|
+
import { runCommand } from "./github-pages.js";
|
|
3
|
+
/** Pinned so deploys are reproducible; checked 2026-10-23. */
|
|
4
|
+
export const WRANGLER = "wrangler@4.152.0";
|
|
5
|
+
/**
|
|
6
|
+
* Uploads dist/ with `wrangler pages deploy`. Auth comes from the environment
|
|
7
|
+
* (CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID) or a prior `wrangler login`.
|
|
8
|
+
* Reports success only when wrangler succeeds AND the deployment URL serves the site.
|
|
9
|
+
*/
|
|
10
|
+
export async function deployCloudflarePages(root, options) {
|
|
11
|
+
const run = options.run ?? runCommand;
|
|
12
|
+
const doFetch = options.fetch ?? fetch;
|
|
13
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
14
|
+
const secrets = [(options.env ?? process.env).CLOUDFLARE_API_TOKEN];
|
|
15
|
+
const args = ["--yes", WRANGLER, "pages", "deploy", "dist", "--project-name", options.project, "--branch", options.branch];
|
|
16
|
+
if (options.commit)
|
|
17
|
+
args.push("--commit-hash", options.commit);
|
|
18
|
+
const res = await run("npx", args, root);
|
|
19
|
+
const output = redact(`${res.stdout}\n${res.stderr}`, secrets);
|
|
20
|
+
if (res.code !== 0) {
|
|
21
|
+
const auth = /auth|login|token|unauthori[sz]ed|10000/i.test(output)
|
|
22
|
+
? " Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, or run `npx wrangler login`."
|
|
23
|
+
: /pages_build_output_dir|project not found|delegat|Workers/i.test(output)
|
|
24
|
+
? ' Cloudflare now creates new sites as Workers rather than Pages projects. Set deploy.target to "cloudflare-workers" in vage.config.ts and run vage deploy again.'
|
|
25
|
+
: "";
|
|
26
|
+
return { status: "failure", branch: options.branch, reason: `wrangler failed (exit ${res.code}).${auth}\n${tail(output)}` };
|
|
27
|
+
}
|
|
28
|
+
const deploymentUrl = output.match(/https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.pages\.dev/gi)?.at(-1);
|
|
29
|
+
if (!deploymentUrl)
|
|
30
|
+
return { status: "unconfirmed", branch: options.branch, reason: `wrangler succeeded but printed no deployment URL.\n${tail(output)}` };
|
|
31
|
+
// Wrangler's success is the provider's confirmation; also check the deployment actually serves.
|
|
32
|
+
for (let i = 0; i < 5; i++) {
|
|
33
|
+
const page = await doFetch(deploymentUrl, { signal: AbortSignal.timeout(20_000) }).catch(() => undefined);
|
|
34
|
+
if (page?.ok)
|
|
35
|
+
return { status: "success", branch: options.branch, deploymentUrl, pageUrl: deploymentUrl };
|
|
36
|
+
await sleep(3000);
|
|
37
|
+
}
|
|
38
|
+
return { status: "unconfirmed", branch: options.branch, deploymentUrl, reason: `Uploaded, but ${deploymentUrl} did not respond with 200 yet. Check it in a minute.` };
|
|
39
|
+
}
|
|
40
|
+
function tail(text) {
|
|
41
|
+
return text.trim().split("\n").slice(-8).join("\n");
|
|
42
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { DeployStatus, Runner } from "./github-pages.js";
|
|
2
|
+
/** In wrangler's own lookup order. */
|
|
3
|
+
export declare const WRANGLER_CONFIGS: string[];
|
|
4
|
+
/** Compatibility date for the generated config; Workers static assets need nothing newer. Checked 2026-10-23. */
|
|
5
|
+
export declare const COMPATIBILITY_DATE = "2026-10-01";
|
|
6
|
+
/** What vagejs needs to know from a project's own wrangler config. */
|
|
7
|
+
export interface WranglerConfig {
|
|
8
|
+
file: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
/** assets.directory, resolved against the project root. */
|
|
11
|
+
assetsDir?: string;
|
|
12
|
+
notFoundHandling?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Reads name and assets settings from the project's wrangler config. Refuses setups where
|
|
16
|
+
* wrangler could pick a different file or Worker than the one shown: several config files,
|
|
17
|
+
* a CLOUDFLARE_ENV environment, or a .wrangler/deploy redirect.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readWranglerConfig(root: string, env?: Record<string, string | undefined>): WranglerConfig | undefined;
|
|
20
|
+
export interface WorkersPlan {
|
|
21
|
+
/** The Worker that will actually be deployed. */
|
|
22
|
+
worker: string;
|
|
23
|
+
/** The project's wrangler config, if it has one. */
|
|
24
|
+
config?: WranglerConfig;
|
|
25
|
+
warnings: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Decides which Worker gets deployed, and refuses setups where the plan and what
|
|
29
|
+
* wrangler would actually do differ (another Worker name, or a folder other than dist/).
|
|
30
|
+
*/
|
|
31
|
+
export declare function planWorkersDeploy(root: string, requested: string | undefined): WorkersPlan;
|
|
32
|
+
export interface WorkersDeployment extends DeployStatus {
|
|
33
|
+
worker: string;
|
|
34
|
+
/** How it was deployed: a new version switched to 100% (existing Worker), or a full first deploy. */
|
|
35
|
+
mode?: "version" | "first-deploy";
|
|
36
|
+
versionId?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Deploys dist/ as a Cloudflare Worker with static assets (what Cloudflare now creates
|
|
40
|
+
* instead of Pages projects; `_headers` and `_redirects` still apply).
|
|
41
|
+
*
|
|
42
|
+
* - With a wrangler config in the project (needed for custom domains and routes), that
|
|
43
|
+
* file is used as-is; `planWorkersDeploy` has already checked its name and folder.
|
|
44
|
+
* Otherwise a temporary config pointing at dist/ is generated, so the project gains no files.
|
|
45
|
+
* - Existing Workers get `wrangler versions upload` + `versions deploy <id>@100%`: the new
|
|
46
|
+
* version takes all traffic without re-applying routes or custom domains, so the site
|
|
47
|
+
* stays up. Only Cloudflare's "Worker doesn't exist" error leads to a first `wrangler deploy`.
|
|
48
|
+
* - Success needs wrangler to succeed AND the live site to answer 200: `siteUrl` when set,
|
|
49
|
+
* else the Worker's workers.dev URL (never a version preview URL).
|
|
50
|
+
*/
|
|
51
|
+
export declare function deployCloudflareWorkers(root: string, options: {
|
|
52
|
+
plan: WorkersPlan;
|
|
53
|
+
tag?: string;
|
|
54
|
+
siteUrl?: string;
|
|
55
|
+
run?: Runner;
|
|
56
|
+
fetch?: typeof fetch;
|
|
57
|
+
sleep?: (ms: number) => Promise<void>;
|
|
58
|
+
env?: Record<string, string | undefined>;
|
|
59
|
+
}): Promise<WorkersDeployment>;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { VageError } from "../core/errors.js";
|
|
5
|
+
import { redact } from "../integrations/http.js";
|
|
6
|
+
import { WRANGLER } from "./cloudflare-pages.js";
|
|
7
|
+
import { runCommand } from "./github-pages.js";
|
|
8
|
+
/** In wrangler's own lookup order. */
|
|
9
|
+
export const WRANGLER_CONFIGS = ["wrangler.json", "wrangler.jsonc", "wrangler.toml"];
|
|
10
|
+
/** Compatibility date for the generated config; Workers static assets need nothing newer. Checked 2026-10-23. */
|
|
11
|
+
export const COMPATIBILITY_DATE = "2026-10-01";
|
|
12
|
+
/**
|
|
13
|
+
* Reads name and assets settings from the project's wrangler config. Refuses setups where
|
|
14
|
+
* wrangler could pick a different file or Worker than the one shown: several config files,
|
|
15
|
+
* a CLOUDFLARE_ENV environment, or a .wrangler/deploy redirect.
|
|
16
|
+
*/
|
|
17
|
+
export function readWranglerConfig(root, env = process.env) {
|
|
18
|
+
const present = WRANGLER_CONFIGS.filter((f) => fs.existsSync(path.join(root, f)));
|
|
19
|
+
if (present.length > 1)
|
|
20
|
+
throw new VageError(`Found ${present.join(" and ")}. Keep one wrangler config so it's clear which Worker gets deployed.`);
|
|
21
|
+
if (fs.existsSync(path.join(root, ".wrangler", "deploy", "config.json"))) {
|
|
22
|
+
throw new VageError(".wrangler/deploy/config.json redirects wrangler to another config. Remove it, or deploy with wrangler directly.");
|
|
23
|
+
}
|
|
24
|
+
const file = present[0];
|
|
25
|
+
if (!file)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (env.CLOUDFLARE_ENV)
|
|
28
|
+
throw new VageError(`CLOUDFLARE_ENV="${env.CLOUDFLARE_ENV}" makes wrangler deploy that environment's Worker. Unset it to deploy with vagejs.`);
|
|
29
|
+
const text = fs.readFileSync(path.join(root, file), "utf8").replace(/^\uFEFF/, "");
|
|
30
|
+
let name, dir, nf;
|
|
31
|
+
if (file.endsWith(".toml")) {
|
|
32
|
+
const lines = text.split(/\r?\n/).map((l) => l.replace(/\s+#.*$|^\s*#.*$/, ""));
|
|
33
|
+
const header = (l) => /^\s*\[/.test(l);
|
|
34
|
+
const firstHeader = lines.findIndex(header);
|
|
35
|
+
const top = firstHeader === -1 ? lines : lines.slice(0, firstHeader);
|
|
36
|
+
const assetsStart = lines.findIndex((l) => /^\s*\[assets\]\s*$/.test(l));
|
|
37
|
+
const assetsEnd = assetsStart === -1 ? -1 : lines.findIndex((l, i) => i > assetsStart && header(l));
|
|
38
|
+
const assets = assetsStart === -1 ? [] : lines.slice(assetsStart + 1, assetsEnd === -1 ? undefined : assetsEnd);
|
|
39
|
+
const value = (block, key) => {
|
|
40
|
+
for (const l of block) {
|
|
41
|
+
const m = new RegExp(`^\\s*${key}\\s*=\\s*(?:"([^"]*)"|'([^']*)')\\s*$`).exec(l);
|
|
42
|
+
if (m)
|
|
43
|
+
return m[1] ?? m[2];
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
};
|
|
47
|
+
name = value(top, "name");
|
|
48
|
+
dir = value(assets, "directory");
|
|
49
|
+
nf = value(assets, "not_found_handling");
|
|
50
|
+
const inline = top.map((l) => /^\s*assets\s*=\s*\{(.*)\}\s*$/.exec(l)?.[1]).find(Boolean);
|
|
51
|
+
if (inline) {
|
|
52
|
+
dir ??= /directory\s*=\s*"([^"]*)"/.exec(inline)?.[1];
|
|
53
|
+
nf ??= /not_found_handling\s*=\s*"([^"]*)"/.exec(inline)?.[1];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
let data;
|
|
58
|
+
try {
|
|
59
|
+
data = JSON.parse(stripJsonComments(text));
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
throw new VageError(`Could not read ${file}: ${err.message}`, path.join(root, file));
|
|
63
|
+
}
|
|
64
|
+
({ name } = data);
|
|
65
|
+
dir = data.assets?.directory;
|
|
66
|
+
nf = data.assets?.not_found_handling;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
file,
|
|
70
|
+
name: typeof name === "string" ? name : undefined,
|
|
71
|
+
assetsDir: typeof dir === "string" ? path.resolve(root, dir) : undefined,
|
|
72
|
+
notFoundHandling: typeof nf === "string" ? nf : undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** JSONC to JSON: drops // and /* *\/ comments and trailing commas outside strings. */
|
|
76
|
+
function stripJsonComments(text) {
|
|
77
|
+
let out = "";
|
|
78
|
+
for (let i = 0; i < text.length; i++) {
|
|
79
|
+
const c = text[i];
|
|
80
|
+
if (c === '"') {
|
|
81
|
+
const end = /"(?:[^"\\]|\\.)*"/y;
|
|
82
|
+
end.lastIndex = i;
|
|
83
|
+
const m = end.exec(text);
|
|
84
|
+
const str = m ? m[0] : text.slice(i);
|
|
85
|
+
out += str;
|
|
86
|
+
i += str.length - 1;
|
|
87
|
+
}
|
|
88
|
+
else if (c === "/" && text[i + 1] === "/") {
|
|
89
|
+
while (i < text.length && text[i] !== "\n")
|
|
90
|
+
i++;
|
|
91
|
+
out += "\n";
|
|
92
|
+
}
|
|
93
|
+
else if (c === "/" && text[i + 1] === "*") {
|
|
94
|
+
i = text.indexOf("*/", i + 2);
|
|
95
|
+
if (i === -1)
|
|
96
|
+
break;
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
else if (c === "," && /^\s*(\/\/[^\n]*\n\s*|\/\*[\s\S]*?\*\/\s*)*[}\]]/.test(text.slice(i + 1))) {
|
|
100
|
+
// trailing comma: drop it
|
|
101
|
+
}
|
|
102
|
+
else
|
|
103
|
+
out += c;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Decides which Worker gets deployed, and refuses setups where the plan and what
|
|
109
|
+
* wrangler would actually do differ (another Worker name, or a folder other than dist/).
|
|
110
|
+
*/
|
|
111
|
+
export function planWorkersDeploy(root, requested) {
|
|
112
|
+
const config = readWranglerConfig(root);
|
|
113
|
+
const warnings = [];
|
|
114
|
+
if (!config) {
|
|
115
|
+
if (!requested)
|
|
116
|
+
throw new VageError("No Worker name. Pass --project <name>, set deploy.cloudflareWorker, or add a wrangler.jsonc.");
|
|
117
|
+
return { worker: requested, warnings };
|
|
118
|
+
}
|
|
119
|
+
if (!config.name)
|
|
120
|
+
throw new VageError(`${config.file} has no "name". Add the Worker's name there.`, path.join(root, config.file));
|
|
121
|
+
if (requested && requested !== config.name) {
|
|
122
|
+
throw new VageError(`${config.file} deploys the Worker "${config.name}", but this deploy asks for "${requested}". ` +
|
|
123
|
+
`Make them match (deploy.cloudflareWorker / --project, or "name" in ${config.file}); vagejs won't deploy a different Worker than it shows.`);
|
|
124
|
+
}
|
|
125
|
+
const dist = path.join(root, "dist");
|
|
126
|
+
if (config.assetsDir !== dist) {
|
|
127
|
+
throw new VageError(`${config.file} must serve the built site: set assets.directory to "dist" (it is ${config.assetsDir ? `"${path.relative(root, config.assetsDir) || "."}"` : "not set"}).`, path.join(root, config.file));
|
|
128
|
+
}
|
|
129
|
+
if (config.notFoundHandling !== "404-page" && fs.existsSync(path.join(root, "src", "pages", "404.md"))) {
|
|
130
|
+
warnings.push(`${config.file}: add "not_found_handling": "404-page" under "assets", or unknown URLs get an empty 404 instead of your 404 page.`);
|
|
131
|
+
}
|
|
132
|
+
return { worker: config.name, config, warnings };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Deploys dist/ as a Cloudflare Worker with static assets (what Cloudflare now creates
|
|
136
|
+
* instead of Pages projects; `_headers` and `_redirects` still apply).
|
|
137
|
+
*
|
|
138
|
+
* - With a wrangler config in the project (needed for custom domains and routes), that
|
|
139
|
+
* file is used as-is; `planWorkersDeploy` has already checked its name and folder.
|
|
140
|
+
* Otherwise a temporary config pointing at dist/ is generated, so the project gains no files.
|
|
141
|
+
* - Existing Workers get `wrangler versions upload` + `versions deploy <id>@100%`: the new
|
|
142
|
+
* version takes all traffic without re-applying routes or custom domains, so the site
|
|
143
|
+
* stays up. Only Cloudflare's "Worker doesn't exist" error leads to a first `wrangler deploy`.
|
|
144
|
+
* - Success needs wrangler to succeed AND the live site to answer 200: `siteUrl` when set,
|
|
145
|
+
* else the Worker's workers.dev URL (never a version preview URL).
|
|
146
|
+
*/
|
|
147
|
+
export async function deployCloudflareWorkers(root, options) {
|
|
148
|
+
const run = options.run ?? runCommand;
|
|
149
|
+
const doFetch = options.fetch ?? fetch;
|
|
150
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
151
|
+
const secrets = [(options.env ?? process.env).CLOUDFLARE_API_TOKEN];
|
|
152
|
+
const { worker } = options.plan;
|
|
153
|
+
let tempDir;
|
|
154
|
+
// Always name the config file, so wrangler can't pick another one (e.g. in a parent folder).
|
|
155
|
+
const configArgs = options.plan.config ? ["--config", path.join(root, options.plan.config.file)] : [];
|
|
156
|
+
if (!options.plan.config) {
|
|
157
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vage-wrangler-"));
|
|
158
|
+
const config = {
|
|
159
|
+
name: worker,
|
|
160
|
+
compatibility_date: COMPATIBILITY_DATE,
|
|
161
|
+
assets: { directory: path.join(root, "dist"), not_found_handling: "404-page" },
|
|
162
|
+
};
|
|
163
|
+
fs.writeFileSync(path.join(tempDir, "wrangler.json"), JSON.stringify(config, null, 2));
|
|
164
|
+
configArgs.push("--config", path.join(tempDir, "wrangler.json"));
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const wrangler = (...args) => run("npx", ["--yes", WRANGLER, ...args, ...configArgs], root);
|
|
168
|
+
const out = (r) => redact(`${r.stdout}\n${r.stderr}`, secrets);
|
|
169
|
+
const failReason = (code, text) => {
|
|
170
|
+
const auth = /auth|login|token|unauthori[sz]ed|10000/i.test(text) ? " Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, or run `npx wrangler login`." : "";
|
|
171
|
+
return `wrangler failed (exit ${code}).${auth}\n${tail(text)}`;
|
|
172
|
+
};
|
|
173
|
+
let mode = "version";
|
|
174
|
+
let versionId;
|
|
175
|
+
let output;
|
|
176
|
+
const tag = options.tag ?? `vage-${Date.now()}`;
|
|
177
|
+
const upload = await wrangler("versions", "upload", "--tag", tag);
|
|
178
|
+
output = out(upload);
|
|
179
|
+
if (upload.code !== 0) {
|
|
180
|
+
// Only a Worker that doesn't exist yet gets a full deploy (which also attaches routes).
|
|
181
|
+
if (!/\b10007\b|worker[^\n]*(?:does not exist|not found)/i.test(output))
|
|
182
|
+
return { status: "failure", worker, reason: failReason(upload.code, output) };
|
|
183
|
+
mode = "first-deploy";
|
|
184
|
+
const first = await wrangler("deploy");
|
|
185
|
+
output = out(first);
|
|
186
|
+
if (first.code !== 0)
|
|
187
|
+
return { status: "failure", worker, mode, reason: failReason(first.code, output) };
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
versionId = /Version ID:\s*([0-9a-f-]{36})/i.exec(output)?.[1];
|
|
191
|
+
if (!versionId)
|
|
192
|
+
return { status: "failure", worker, reason: `wrangler uploaded but printed no version ID.\n${tail(output)}` };
|
|
193
|
+
const switched = await wrangler("versions", "deploy", `${versionId}@100%`, "--yes");
|
|
194
|
+
if (switched.code !== 0)
|
|
195
|
+
return { status: "failure", worker, versionId, reason: `Version ${versionId} uploaded but not deployed. ${failReason(switched.code, out(switched))}` };
|
|
196
|
+
output += `\n${out(switched)}`;
|
|
197
|
+
}
|
|
198
|
+
// Lines mentioning "preview" carry version preview URLs, which show the new version even when the live site doesn't.
|
|
199
|
+
const workersDev = output
|
|
200
|
+
.split("\n")
|
|
201
|
+
.filter((line) => !/preview/i.test(line))
|
|
202
|
+
.join("\n")
|
|
203
|
+
.match(/https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev/gi)
|
|
204
|
+
?.at(-1);
|
|
205
|
+
const pageUrl = options.siteUrl ?? workersDev;
|
|
206
|
+
if (!pageUrl)
|
|
207
|
+
return { status: "unconfirmed", worker, mode, versionId, reason: "Deployed, but there is no URL to check: set `site` in vage.config.ts." };
|
|
208
|
+
for (let i = 0; i < 5; i++) {
|
|
209
|
+
const page = await doFetch(pageUrl, { signal: AbortSignal.timeout(20_000) }).catch(() => undefined);
|
|
210
|
+
if (page?.ok)
|
|
211
|
+
return { status: "success", worker, mode, versionId, pageUrl };
|
|
212
|
+
await sleep(3000);
|
|
213
|
+
}
|
|
214
|
+
return { status: "unconfirmed", worker, mode, versionId, pageUrl, reason: `Deployed, but ${pageUrl} did not respond with 200 yet. Check it in a minute.` };
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
if (tempDir)
|
|
218
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function tail(text) {
|
|
222
|
+
return text.trim().split("\n").slice(-8).join("\n");
|
|
223
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface GitResult {
|
|
2
|
+
stdout: string;
|
|
3
|
+
stderr: string;
|
|
4
|
+
code: number;
|
|
5
|
+
}
|
|
6
|
+
/** Runs git without a shell. Non-zero exits are returned, not thrown, so callers can explain them. */
|
|
7
|
+
export declare function gitRaw(cwd: string, args: string[]): Promise<GitResult>;
|
|
8
|
+
export declare function git(cwd: string, args: string[]): Promise<string>;
|
|
9
|
+
export interface ChangedFile {
|
|
10
|
+
/** Two-letter porcelain status, e.g. "??", " M", "D ". */
|
|
11
|
+
status: string;
|
|
12
|
+
path: string;
|
|
13
|
+
}
|
|
14
|
+
/** Changes git would commit with `git add -A`, honoring .gitignore. */
|
|
15
|
+
export declare function changedFiles(cwd: string): Promise<ChangedFile[]>;
|
|
16
|
+
/**
|
|
17
|
+
* Routes of pages changed (or deleted) since `ref`. The ref is resolved to a
|
|
18
|
+
* commit SHA first, so it can never be read as a git option such as --output=<file>.
|
|
19
|
+
*/
|
|
20
|
+
export declare function changedPageRoutes(cwd: string, ref: string): Promise<string[]>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { VageError } from "../core/errors.js";
|
|
4
|
+
import { routeForPage } from "../core/routes.js";
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
/** Runs git without a shell. Non-zero exits are returned, not thrown, so callers can explain them. */
|
|
7
|
+
export async function gitRaw(cwd, args) {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout, stderr } = await exec("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 });
|
|
10
|
+
return { stdout, stderr, code: 0 };
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
const e = err;
|
|
14
|
+
if (e.code === "ENOENT")
|
|
15
|
+
throw new VageError("git is not installed or not on PATH. Install git and retry.");
|
|
16
|
+
return { stdout: e.stdout ?? "", stderr: e.stderr ?? "", code: typeof e.code === "number" ? e.code : 1 };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function git(cwd, args) {
|
|
20
|
+
const res = await gitRaw(cwd, args);
|
|
21
|
+
if (res.code !== 0)
|
|
22
|
+
throw new VageError(`git ${args[0]} failed: ${(res.stderr || res.stdout).trim()}`);
|
|
23
|
+
return res.stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
/** Changes git would commit with `git add -A`, honoring .gitignore. */
|
|
26
|
+
export async function changedFiles(cwd) {
|
|
27
|
+
const out = (await gitRaw(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"])).stdout;
|
|
28
|
+
const entries = out.split("\0").filter(Boolean);
|
|
29
|
+
const files = [];
|
|
30
|
+
for (let i = 0; i < entries.length; i++) {
|
|
31
|
+
const entry = entries[i];
|
|
32
|
+
const status = entry.slice(0, 2);
|
|
33
|
+
files.push({ status, path: entry.slice(3) });
|
|
34
|
+
if (status.startsWith("R") || status.startsWith("C"))
|
|
35
|
+
i++; // skip the rename source
|
|
36
|
+
}
|
|
37
|
+
return files;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Routes of pages changed (or deleted) since `ref`. The ref is resolved to a
|
|
41
|
+
* commit SHA first, so it can never be read as a git option such as --output=<file>.
|
|
42
|
+
*/
|
|
43
|
+
export async function changedPageRoutes(cwd, ref) {
|
|
44
|
+
if (ref.startsWith("-"))
|
|
45
|
+
throw new VageError(`Invalid ref "${ref}".`);
|
|
46
|
+
const resolved = await gitRaw(cwd, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${ref}^{commit}`]);
|
|
47
|
+
if (resolved.code !== 0)
|
|
48
|
+
throw new VageError(`"${ref}" is not a commit in this repository.`);
|
|
49
|
+
const changed = await git(cwd, ["diff", "--name-only", "--relative", resolved.stdout.trim(), "--", "src/pages"]);
|
|
50
|
+
return changed
|
|
51
|
+
.split("\n")
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.map((f) => routeForPage(f.replace(/^src\/pages\//, "")))
|
|
54
|
+
.filter((r) => Boolean(r));
|
|
55
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare const PAGES_WORKFLOW = ".github/workflows/deploy.yml";
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Actions workflow: check and build on every push to `branch`, then
|
|
4
|
+
* deploy dist/ to GitHub Pages. Action majors checked 2026-10-23 against
|
|
5
|
+
* https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages
|
|
6
|
+
* and each action's latest release. Least privilege: only the deploy job can write to Pages.
|
|
7
|
+
*/
|
|
8
|
+
export declare function pagesWorkflow(branch: string): string;
|
|
9
|
+
export type Runner = (cmd: string, args: string[], cwd: string) => Promise<{
|
|
10
|
+
code: number;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
}>;
|
|
14
|
+
export declare const runCommand: Runner;
|
|
15
|
+
export interface DeployStatus {
|
|
16
|
+
status: "success" | "failure" | "unconfirmed";
|
|
17
|
+
runUrl?: string;
|
|
18
|
+
pageUrl?: string;
|
|
19
|
+
reason?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Turns on Pages with "GitHub Actions" as the source if it is not enabled yet. Uses the gh CLI. */
|
|
22
|
+
export declare function ensurePagesEnabled(cwd: string, run?: Runner): Promise<"enabled" | "already-enabled" | {
|
|
23
|
+
manual: string;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* Waits for the Pages workflow run for `sha` and reports what GitHub says.
|
|
27
|
+
* Without gh, or if the run never appears, the result is "unconfirmed", never success.
|
|
28
|
+
*/
|
|
29
|
+
export declare function confirmPagesDeployment(cwd: string, sha: string, options?: {
|
|
30
|
+
run?: Runner;
|
|
31
|
+
sleep?: (ms: number) => Promise<void>;
|
|
32
|
+
findRunAttempts?: number;
|
|
33
|
+
}): Promise<DeployStatus>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
export const PAGES_WORKFLOW = ".github/workflows/deploy.yml";
|
|
3
|
+
/**
|
|
4
|
+
* GitHub Actions workflow: check and build on every push to `branch`, then
|
|
5
|
+
* deploy dist/ to GitHub Pages. Action majors checked 2026-10-23 against
|
|
6
|
+
* https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages
|
|
7
|
+
* and each action's latest release. Least privilege: only the deploy job can write to Pages.
|
|
8
|
+
*/
|
|
9
|
+
export function pagesWorkflow(branch) {
|
|
10
|
+
return `name: Deploy to GitHub Pages
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
branches: [${JSON.stringify(branch)}]
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
|
|
17
|
+
concurrency:
|
|
18
|
+
group: pages
|
|
19
|
+
cancel-in-progress: false
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
build:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v7
|
|
28
|
+
- uses: actions/setup-node@v7
|
|
29
|
+
with:
|
|
30
|
+
node-version: 22
|
|
31
|
+
cache: npm
|
|
32
|
+
- run: npm ci
|
|
33
|
+
- run: npx vage check
|
|
34
|
+
- run: npx vage build
|
|
35
|
+
- uses: actions/configure-pages@v6
|
|
36
|
+
- uses: actions/upload-pages-artifact@v5
|
|
37
|
+
with:
|
|
38
|
+
path: dist
|
|
39
|
+
|
|
40
|
+
deploy:
|
|
41
|
+
needs: build
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
permissions:
|
|
44
|
+
pages: write
|
|
45
|
+
id-token: write
|
|
46
|
+
environment:
|
|
47
|
+
name: github-pages
|
|
48
|
+
url: \${{ steps.deployment.outputs.page_url }}
|
|
49
|
+
steps:
|
|
50
|
+
- id: deployment
|
|
51
|
+
uses: actions/deploy-pages@v5
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
export const runCommand = (cmd, args, cwd) => new Promise((resolve) => {
|
|
55
|
+
execFile(cmd, args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
56
|
+
const code = err?.code;
|
|
57
|
+
resolve({ code: !err ? 0 : code === "ENOENT" ? 127 : typeof code === "number" ? code : 1, stdout, stderr });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
/** Turns on Pages with "GitHub Actions" as the source if it is not enabled yet. Uses the gh CLI. */
|
|
61
|
+
export async function ensurePagesEnabled(cwd, run = runCommand) {
|
|
62
|
+
const manual = 'Enable it in the repository: Settings → Pages → Build and deployment → Source: "GitHub Actions".';
|
|
63
|
+
if ((await run("gh", ["auth", "status"], cwd)).code !== 0)
|
|
64
|
+
return { manual: `GitHub CLI (gh) is not available or not logged in. ${manual}` };
|
|
65
|
+
if ((await run("gh", ["api", "repos/{owner}/{repo}/pages"], cwd)).code === 0)
|
|
66
|
+
return "already-enabled";
|
|
67
|
+
const created = await run("gh", ["api", "-X", "POST", "repos/{owner}/{repo}/pages", "-f", "build_type=workflow"], cwd);
|
|
68
|
+
return created.code === 0 ? "enabled" : { manual: `Could not enable Pages automatically (${created.stderr.trim()}). ${manual}` };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Waits for the Pages workflow run for `sha` and reports what GitHub says.
|
|
72
|
+
* Without gh, or if the run never appears, the result is "unconfirmed", never success.
|
|
73
|
+
*/
|
|
74
|
+
export async function confirmPagesDeployment(cwd, sha, options = {}) {
|
|
75
|
+
const run = options.run ?? runCommand;
|
|
76
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
77
|
+
if ((await run("gh", ["auth", "status"], cwd)).code !== 0) {
|
|
78
|
+
return { status: "unconfirmed", reason: "GitHub CLI (gh) is not available or not logged in; check the Actions tab for the deploy run." };
|
|
79
|
+
}
|
|
80
|
+
let runId;
|
|
81
|
+
let runUrl;
|
|
82
|
+
for (let i = 0; i < (options.findRunAttempts ?? 20) && runId === undefined; i++) {
|
|
83
|
+
const list = await run("gh", ["run", "list", "--workflow", "deploy.yml", "--commit", sha, "--json", "databaseId,url", "--limit", "1"], cwd);
|
|
84
|
+
const [found] = list.code === 0 ? JSON.parse(list.stdout || "[]") : [];
|
|
85
|
+
if (found)
|
|
86
|
+
({ databaseId: runId, url: runUrl } = found);
|
|
87
|
+
else
|
|
88
|
+
await sleep(3000);
|
|
89
|
+
}
|
|
90
|
+
if (runId === undefined)
|
|
91
|
+
return { status: "unconfirmed", reason: `No deploy.yml run found for ${sha.slice(0, 7)}. Is the workflow on the pushed branch?` };
|
|
92
|
+
await run("gh", ["run", "watch", String(runId), "--exit-status", "--interval", "5"], cwd);
|
|
93
|
+
const view = await run("gh", ["run", "view", String(runId), "--json", "conclusion,url"], cwd);
|
|
94
|
+
const conclusion = view.code === 0 ? JSON.parse(view.stdout).conclusion : "unknown";
|
|
95
|
+
if (conclusion !== "success")
|
|
96
|
+
return { status: "failure", runUrl, reason: `Deploy run finished with "${conclusion}". See the run for logs.` };
|
|
97
|
+
const pages = await run("gh", ["api", "repos/{owner}/{repo}/pages", "--jq", ".html_url"], cwd);
|
|
98
|
+
return { status: "success", runUrl, pageUrl: pages.code === 0 ? pages.stdout.trim() || undefined : undefined };
|
|
99
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SiteConfig } from "../core/config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Content Security Policy derived from the built HTML: sha256 hashes of every
|
|
4
|
+
* inline script and <style>, the external origins layouts load, and the
|
|
5
|
+
* documented domains of configured analytics. Scripts are locked down (that
|
|
6
|
+
* is where XSS protection comes from); images, fonts and media may come from
|
|
7
|
+
* any https origin so ordinary sites don't break.
|
|
8
|
+
*/
|
|
9
|
+
export declare function contentSecurityPolicy(pagesHtml: string[], site: SiteConfig): string;
|
|
10
|
+
/** Cloudflare Pages / Netlify `_headers` for every path. */
|
|
11
|
+
export declare function headersFile(pagesHtml: string[], site: SiteConfig): string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { parse } from "node-html-parser";
|
|
3
|
+
import { VageError } from "../core/errors.js";
|
|
4
|
+
import { ruleData } from "../guidelines/pack.js";
|
|
5
|
+
// https://developers.cloudflare.com/pages/configuration/headers/ (rule pack "cloudflare.headers", "csp.providers")
|
|
6
|
+
const { maxLineLength } = ruleData("cloudflare.headers");
|
|
7
|
+
const PROVIDERS = ruleData("csp.providers");
|
|
8
|
+
const EXECUTABLE = /^(|module|text\/javascript|application\/javascript)$/i;
|
|
9
|
+
/**
|
|
10
|
+
* Content Security Policy derived from the built HTML: sha256 hashes of every
|
|
11
|
+
* inline script and <style>, the external origins layouts load, and the
|
|
12
|
+
* documented domains of configured analytics. Scripts are locked down (that
|
|
13
|
+
* is where XSS protection comes from); images, fonts and media may come from
|
|
14
|
+
* any https origin so ordinary sites don't break.
|
|
15
|
+
*/
|
|
16
|
+
export function contentSecurityPolicy(pagesHtml, site) {
|
|
17
|
+
const sources = {
|
|
18
|
+
"script-src": new Set(["'self'"]),
|
|
19
|
+
"style-src": new Set(["'self'"]),
|
|
20
|
+
"img-src": new Set(["'self'", "data:", "https:"]),
|
|
21
|
+
"font-src": new Set(["'self'", "data:", "https:"]),
|
|
22
|
+
"connect-src": new Set(["'self'"]),
|
|
23
|
+
"frame-src": new Set(),
|
|
24
|
+
"media-src": new Set(["'self'", "https:"]),
|
|
25
|
+
};
|
|
26
|
+
const hash = (text) => `'sha256-${createHash("sha256").update(text, "utf8").digest("base64")}'`;
|
|
27
|
+
const origin = (url) => {
|
|
28
|
+
if (!url || !/^https?:\/\//i.test(url))
|
|
29
|
+
return undefined;
|
|
30
|
+
const o = new URL(url).origin;
|
|
31
|
+
return site.site && o === site.site ? undefined : o;
|
|
32
|
+
};
|
|
33
|
+
for (const html of pagesHtml) {
|
|
34
|
+
const doc = parse(html);
|
|
35
|
+
for (const s of doc.querySelectorAll("script")) {
|
|
36
|
+
if (!EXECUTABLE.test(s.getAttribute("type") ?? ""))
|
|
37
|
+
continue; // e.g. JSON-LD is data, not code
|
|
38
|
+
const src = s.getAttribute("src");
|
|
39
|
+
if (src) {
|
|
40
|
+
const o = origin(src);
|
|
41
|
+
if (o)
|
|
42
|
+
sources["script-src"].add(o);
|
|
43
|
+
}
|
|
44
|
+
else if (s.rawText.trim())
|
|
45
|
+
sources["script-src"].add(hash(s.rawText));
|
|
46
|
+
}
|
|
47
|
+
for (const s of doc.querySelectorAll("style"))
|
|
48
|
+
sources["style-src"].add(hash(s.rawText));
|
|
49
|
+
for (const l of doc.querySelectorAll('link[rel="stylesheet"]')) {
|
|
50
|
+
const o = origin(l.getAttribute("href"));
|
|
51
|
+
if (o)
|
|
52
|
+
sources["style-src"].add(o);
|
|
53
|
+
}
|
|
54
|
+
for (const f of doc.querySelectorAll("iframe[src]")) {
|
|
55
|
+
const o = origin(f.getAttribute("src"));
|
|
56
|
+
if (o)
|
|
57
|
+
sources["frame-src"].add(o);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const { ga4, gtm, plausible, clarity } = site.integrations;
|
|
61
|
+
const add = (extra) => {
|
|
62
|
+
for (const [d, list] of Object.entries(extra))
|
|
63
|
+
list.forEach((v) => sources[d].add(v));
|
|
64
|
+
};
|
|
65
|
+
if (ga4?.measurementId || gtm?.containerId)
|
|
66
|
+
add(PROVIDERS.google);
|
|
67
|
+
if (clarity)
|
|
68
|
+
add(PROVIDERS.clarity);
|
|
69
|
+
if (plausible) {
|
|
70
|
+
// Plausible's snippet loads its script from, and reports to, the same origin.
|
|
71
|
+
for (const m of plausible.snippet.matchAll(/src=["']([^"']+)["']/g)) {
|
|
72
|
+
const o = origin(m[1]);
|
|
73
|
+
if (o)
|
|
74
|
+
add({ "script-src": [o], "connect-src": [o] });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
add(site.security.cspSources);
|
|
78
|
+
const directives = [
|
|
79
|
+
"default-src 'self'",
|
|
80
|
+
...Object.entries(sources).filter(([, v]) => v.size).map(([d, v]) => `${d} ${[...v].join(" ")}`),
|
|
81
|
+
"style-src-attr 'unsafe-inline'", // style="" attributes in content; CSS can't run script
|
|
82
|
+
"object-src 'none'",
|
|
83
|
+
"base-uri 'self'",
|
|
84
|
+
"form-action 'self'",
|
|
85
|
+
"frame-ancestors 'self'",
|
|
86
|
+
"upgrade-insecure-requests",
|
|
87
|
+
];
|
|
88
|
+
return directives.join("; ");
|
|
89
|
+
}
|
|
90
|
+
/** Cloudflare Pages / Netlify `_headers` for every path. */
|
|
91
|
+
export function headersFile(pagesHtml, site) {
|
|
92
|
+
const lines = ["# Generated by vagejs (security in vage.config.ts)", "/*", " X-Content-Type-Options: nosniff", " Referrer-Policy: strict-origin-when-cross-origin", " X-Frame-Options: SAMEORIGIN", " Permissions-Policy: camera=(), microphone=(), geolocation=()"];
|
|
93
|
+
if (site.security.csp !== "off") {
|
|
94
|
+
const name = site.security.csp === "report-only" ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";
|
|
95
|
+
lines.push(` ${name}: ${contentSecurityPolicy(pagesHtml, site)}`);
|
|
96
|
+
}
|
|
97
|
+
const tooLong = lines.find((l) => l.length > maxLineLength);
|
|
98
|
+
if (tooLong) {
|
|
99
|
+
throw new VageError(`The generated Content-Security-Policy is ${tooLong.length} characters; _headers lines are limited to ${maxLineLength}. ` +
|
|
100
|
+
"Reduce distinct inline scripts/styles (move them to files in public/), or set security.csp to \"off\".");
|
|
101
|
+
}
|
|
102
|
+
return `${lines.join("\n")}\n`;
|
|
103
|
+
}
|