vagejs 0.0.0-stage → 0.1.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 +79 -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 +29 -0
- package/dist/checks/check.js +330 -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 +604 -0
- package/dist/cli/info.d.ts +47 -0
- package/dist/cli/info.js +55 -0
- package/dist/content/markdown.d.ts +26 -0
- package/dist/content/markdown.js +51 -0
- package/dist/content/sanitize.d.ts +7 -0
- package/dist/content/sanitize.js +17 -0
- package/dist/core/build.d.ts +25 -0
- package/dist/core/build.js +54 -0
- package/dist/core/config.d.ts +171 -0
- package/dist/core/config.js +243 -0
- package/dist/core/errors.d.ts +5 -0
- package/dist/core/errors.js +9 -0
- package/dist/core/html.d.ts +2 -0
- package/dist/core/html.js +11 -0
- package/dist/core/loader.d.ts +10 -0
- package/dist/core/loader.js +16 -0
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +14 -0
- package/dist/core/render.d.ts +37 -0
- package/dist/core/render.js +62 -0
- package/dist/core/routes.d.ts +24 -0
- package/dist/core/routes.js +70 -0
- package/dist/core/site.d.ts +31 -0
- package/dist/core/site.js +159 -0
- package/dist/core/validate.d.ts +10 -0
- package/dist/core/validate.js +30 -0
- package/dist/deploy/cloudflare-pages.d.ts +20 -0
- package/dist/deploy/cloudflare-pages.js +40 -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 +102 -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 +11 -0
- package/dist/index.js +9 -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/workflows/ci.yml +28 -0
- package/templates/starter/AGENTS.md +145 -0
- package/templates/starter/CLAUDE.md +3 -0
- package/templates/starter/_gitignore +8 -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/default.ts +37 -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/index.md +8 -0
- package/templates/starter/vage.config.ts +34 -0
|
@@ -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 VAGE JS (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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ChangedFile } from "./git.js";
|
|
2
|
+
export interface PublishPlan {
|
|
3
|
+
root: string;
|
|
4
|
+
/** True when `git init` will run (the folder is not a repository yet). */
|
|
5
|
+
initRepo: boolean;
|
|
6
|
+
branch: string;
|
|
7
|
+
/** Remote URL that will be pushed to, if any. */
|
|
8
|
+
remote?: string;
|
|
9
|
+
/** True when `--remote` will be added as "origin". */
|
|
10
|
+
addRemote: boolean;
|
|
11
|
+
files: ChangedFile[];
|
|
12
|
+
/** Files VAGE JS will create (they do not exist yet). */
|
|
13
|
+
extraFiles: Record<string, string>;
|
|
14
|
+
message: string;
|
|
15
|
+
/** Local commits not yet on the remote (before this commit). */
|
|
16
|
+
unpushedCommits: number;
|
|
17
|
+
warnings: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface PublishResult {
|
|
20
|
+
plan: PublishPlan;
|
|
21
|
+
status: "published" | "committed" | "aborted" | "up-to-date";
|
|
22
|
+
commit?: string;
|
|
23
|
+
/** Set only after `git ls-remote` shows the remote branch at our commit. */
|
|
24
|
+
confirmedRemote?: {
|
|
25
|
+
url: string;
|
|
26
|
+
branch: string;
|
|
27
|
+
sha: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface PublishOptions {
|
|
31
|
+
remote?: string;
|
|
32
|
+
branch?: string;
|
|
33
|
+
message?: string;
|
|
34
|
+
/** Receives the full plan; nothing is changed unless it resolves true. */
|
|
35
|
+
confirm: (plan: PublishPlan) => Promise<boolean>;
|
|
36
|
+
/** Files to create if missing, before committing (e.g. a deploy workflow): relative path -> content, given the branch. */
|
|
37
|
+
extraFiles?: (branch: string) => Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Safe commit-and-push: runs check and build, refuses secrets and build
|
|
41
|
+
* output, shows the plan, asks for confirmation, never force-pushes, and
|
|
42
|
+
* reports success only after the remote confirms the commit.
|
|
43
|
+
*/
|
|
44
|
+
export declare function publish(root: string, options: PublishOptions): Promise<PublishResult>;
|
|
45
|
+
export declare function planPublish(root: string, options: Omit<PublishOptions, "confirm">): Promise<PublishPlan>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { check } from "../checks/check.js";
|
|
5
|
+
import { build } from "../core/build.js";
|
|
6
|
+
import { VageError } from "../core/errors.js";
|
|
7
|
+
import { assertBranchName, assertGitRemote } from "../core/validate.js";
|
|
8
|
+
import { changedFiles, git, gitRaw } from "./git.js";
|
|
9
|
+
import { findSecrets } from "./secrets.js";
|
|
10
|
+
const BLOCKED_DIRS = ["node_modules/", "dist/"];
|
|
11
|
+
/**
|
|
12
|
+
* Safe commit-and-push: runs check and build, refuses secrets and build
|
|
13
|
+
* output, shows the plan, asks for confirmation, never force-pushes, and
|
|
14
|
+
* reports success only after the remote confirms the commit.
|
|
15
|
+
*/
|
|
16
|
+
export async function publish(root, options) {
|
|
17
|
+
const plan = await planPublish(root, options);
|
|
18
|
+
const nothingToCommit = plan.files.length === 0;
|
|
19
|
+
if (nothingToCommit && plan.unpushedCommits === 0 && !plan.initRepo)
|
|
20
|
+
return { plan, status: "up-to-date" };
|
|
21
|
+
if (!(await options.confirm(plan)))
|
|
22
|
+
return { plan, status: "aborted" };
|
|
23
|
+
const cwd = plan.root;
|
|
24
|
+
if (plan.initRepo)
|
|
25
|
+
await git(cwd, ["init", "-b", plan.branch]);
|
|
26
|
+
if (plan.addRemote)
|
|
27
|
+
await git(cwd, ["remote", "add", "--", "origin", plan.remote]);
|
|
28
|
+
for (const [rel, content] of Object.entries(plan.extraFiles)) {
|
|
29
|
+
fs.mkdirSync(path.dirname(path.join(cwd, rel)), { recursive: true });
|
|
30
|
+
fs.writeFileSync(path.join(cwd, rel), content);
|
|
31
|
+
}
|
|
32
|
+
let commit;
|
|
33
|
+
if (!nothingToCommit) {
|
|
34
|
+
await git(cwd, ["add", "-A"]);
|
|
35
|
+
// Re-check what is actually staged: the tree may have changed since the plan was shown.
|
|
36
|
+
const staged = (await git(cwd, ["diff", "--cached", "--name-only", "-z"])).split("\0").filter(Boolean);
|
|
37
|
+
const planned = new Set(plan.files.map((f) => f.path));
|
|
38
|
+
const unexpected = staged.filter((f) => !planned.has(f));
|
|
39
|
+
if (unexpected.length) {
|
|
40
|
+
await git(cwd, ["reset", "-q"]);
|
|
41
|
+
throw new VageError(`Files changed after the plan was shown and were not committed: ${unexpected.join(", ")}. Re-run to review.`);
|
|
42
|
+
}
|
|
43
|
+
await git(cwd, ["commit", "-q", "-m", plan.message]);
|
|
44
|
+
commit = await git(cwd, ["rev-parse", "HEAD"]);
|
|
45
|
+
}
|
|
46
|
+
commit ??= await git(cwd, ["rev-parse", "HEAD"]);
|
|
47
|
+
if (!plan.remote)
|
|
48
|
+
return { plan, status: "committed", commit };
|
|
49
|
+
const push = await gitRaw(cwd, ["push", "-u", "origin", "--", plan.branch]);
|
|
50
|
+
if (push.code !== 0) {
|
|
51
|
+
const out = push.stderr + push.stdout;
|
|
52
|
+
if (/rejected|non-fast-forward|fetch first/i.test(out)) {
|
|
53
|
+
throw new VageError(`Push rejected: origin/${plan.branch} has commits you don't have. Your commit is saved locally. ` +
|
|
54
|
+
`Run "git pull --rebase origin ${plan.branch}", resolve any conflicts, then publish again. VAGE JS never force-pushes.`);
|
|
55
|
+
}
|
|
56
|
+
throw new VageError(`Push failed (your commit is saved locally): ${out.trim()}`);
|
|
57
|
+
}
|
|
58
|
+
const remoteSha = (await git(cwd, ["ls-remote", "origin", `refs/heads/${plan.branch}`])).split(/\s/)[0];
|
|
59
|
+
if (remoteSha !== commit) {
|
|
60
|
+
throw new VageError(`Push finished but origin/${plan.branch} is at ${remoteSha || "nothing"}, not ${commit}. Check the remote before retrying.`);
|
|
61
|
+
}
|
|
62
|
+
return { plan, status: "published", commit, confirmedRemote: { url: plan.remote, branch: plan.branch, sha: remoteSha } };
|
|
63
|
+
}
|
|
64
|
+
export async function planPublish(root, options) {
|
|
65
|
+
root = path.resolve(root);
|
|
66
|
+
if (options.branch)
|
|
67
|
+
assertBranchName(options.branch);
|
|
68
|
+
if (options.remote)
|
|
69
|
+
assertGitRemote(options.remote);
|
|
70
|
+
const checked = await check(root);
|
|
71
|
+
if (checked.errors > 0)
|
|
72
|
+
throw new VageError(`vage check found ${checked.errors} error(s). Fix them (run "vage check") before publishing.`);
|
|
73
|
+
await build(root);
|
|
74
|
+
const top = await gitRaw(root, ["rev-parse", "--show-toplevel"]);
|
|
75
|
+
const initRepo = top.code !== 0;
|
|
76
|
+
const cwd = initRepo ? root : fs.realpathSync(top.stdout.trim());
|
|
77
|
+
const warnings = [];
|
|
78
|
+
let branch;
|
|
79
|
+
if (initRepo) {
|
|
80
|
+
branch = options.branch ?? "main";
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
branch = await git(cwd, ["branch", "--show-current"]);
|
|
84
|
+
if (!branch)
|
|
85
|
+
throw new VageError("HEAD is detached. Check out a branch before publishing.");
|
|
86
|
+
if (options.branch && options.branch !== branch) {
|
|
87
|
+
throw new VageError(`You are on "${branch}", not "${options.branch}". Switch branches yourself first; VAGE JS won't do it for you.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const origin = initRepo ? undefined : await gitRaw(cwd, ["remote", "get-url", "origin"]);
|
|
91
|
+
const existingRemote = origin?.code === 0 ? origin.stdout.trim() : undefined;
|
|
92
|
+
if (options.remote && existingRemote && options.remote !== existingRemote) {
|
|
93
|
+
throw new VageError(`origin is already ${existingRemote}. Change it yourself with "git remote set-url" if that is intended.`);
|
|
94
|
+
}
|
|
95
|
+
const remote = existingRemote ?? options.remote;
|
|
96
|
+
if (!remote)
|
|
97
|
+
warnings.push('No remote: changes will be committed locally only. Pass --remote <git-url> to push (e.g. a repo created with "gh repo create").');
|
|
98
|
+
const files = initRepo ? await filesForNewRepo(root) : await changedFiles(cwd);
|
|
99
|
+
const blocked = files.filter((f) => BLOCKED_DIRS.some((d) => f.path.startsWith(d) || f.path.includes(`/${d}`)));
|
|
100
|
+
if (blocked.length) {
|
|
101
|
+
throw new VageError(`Refusing to commit build output or dependencies (${blocked[0].path}, …). Add node_modules/ and dist/ to .gitignore.`);
|
|
102
|
+
}
|
|
103
|
+
const secrets = findSecrets(cwd, files.filter((f) => !f.status.includes("D")).map((f) => f.path));
|
|
104
|
+
if (secrets.length) {
|
|
105
|
+
throw new VageError(`Refusing to commit possible secrets:\n${secrets.map((s) => ` ${s.path}: ${s.reason}`).join("\n")}\n` +
|
|
106
|
+
"Add them to .gitignore (and rotate any real credential that was exposed). False positive? Commit manually with git.");
|
|
107
|
+
}
|
|
108
|
+
const pkgPath = path.join(root, "package.json");
|
|
109
|
+
if (fs.existsSync(pkgPath)) {
|
|
110
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
111
|
+
const local = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }).filter(([, v]) => String(v).startsWith("file:"));
|
|
112
|
+
if (local.length)
|
|
113
|
+
warnings.push(`package.json uses local "file:" dependencies (${local.map(([k]) => k).join(", ")}); CI and other machines cannot install them.`);
|
|
114
|
+
}
|
|
115
|
+
let unpushedCommits = 0;
|
|
116
|
+
if (!initRepo && remote && (await gitRaw(cwd, ["rev-parse", "HEAD"])).code === 0) {
|
|
117
|
+
const tracking = await gitRaw(cwd, ["rev-list", "--count", `origin/${branch}..HEAD`]);
|
|
118
|
+
unpushedCommits = tracking.code === 0 ? Number(tracking.stdout.trim()) : Number(await git(cwd, ["rev-list", "--count", "HEAD"]));
|
|
119
|
+
}
|
|
120
|
+
// Generated files are only created when missing, never overwritten; they are part of the commit.
|
|
121
|
+
const extraFiles = Object.fromEntries(Object.entries(options.extraFiles?.(branch) ?? {}).filter(([rel]) => !fs.existsSync(path.join(cwd, rel))));
|
|
122
|
+
for (const rel of Object.keys(extraFiles))
|
|
123
|
+
files.push({ status: "??", path: rel });
|
|
124
|
+
return {
|
|
125
|
+
root: cwd,
|
|
126
|
+
initRepo,
|
|
127
|
+
branch,
|
|
128
|
+
remote,
|
|
129
|
+
addRemote: Boolean(options.remote && !existingRemote),
|
|
130
|
+
files,
|
|
131
|
+
extraFiles,
|
|
132
|
+
message: options.message ?? "Publish site with VAGE JS",
|
|
133
|
+
unpushedCommits,
|
|
134
|
+
warnings,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Lists what `git add -A` would stage in a folder that is not a repo yet, honoring .gitignore, without touching it. */
|
|
138
|
+
async function filesForNewRepo(root) {
|
|
139
|
+
const gitDir = fs.mkdtempSync(path.join(os.tmpdir(), "vage-plan-"));
|
|
140
|
+
try {
|
|
141
|
+
await git(root, ["--git-dir", gitDir, "--work-tree", root, "init", "-q"]);
|
|
142
|
+
const out = await git(root, ["--git-dir", gitDir, "--work-tree", root, "ls-files", "--others", "--exclude-standard", "-z"]);
|
|
143
|
+
return out.split("\0").filter(Boolean).sort().map((p) => ({ status: "??", path: p }));
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
fs.rmSync(gitDir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SiteRedirect } from "../core/site.js";
|
|
2
|
+
/**
|
|
3
|
+
* Cloudflare Pages `_redirects`: real 301s for configured redirects. The
|
|
4
|
+
* meta-refresh pages stay as a fallback for hosts without redirect support.
|
|
5
|
+
* Both "/old" and "/old/" are listed so either form redirects.
|
|
6
|
+
*/
|
|
7
|
+
export declare function cloudflareRedirects(redirects: SiteRedirect[]): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { VageError } from "../core/errors.js";
|
|
2
|
+
import { ruleData } from "../guidelines/pack.js";
|
|
3
|
+
// https://developers.cloudflare.com/pages/configuration/redirects/ (rule pack "cloudflare.redirects")
|
|
4
|
+
const { maxStaticRules } = ruleData("cloudflare.redirects");
|
|
5
|
+
/**
|
|
6
|
+
* Cloudflare Pages `_redirects`: real 301s for configured redirects. The
|
|
7
|
+
* meta-refresh pages stay as a fallback for hosts without redirect support.
|
|
8
|
+
* Both "/old" and "/old/" are listed so either form redirects.
|
|
9
|
+
*/
|
|
10
|
+
export function cloudflareRedirects(redirects) {
|
|
11
|
+
const lines = redirects.flatMap((r) => {
|
|
12
|
+
const sources = r.from === "/" ? ["/"] : [r.from, r.from.replace(/\/$/, "")];
|
|
13
|
+
return sources.map((from) => `${from} ${r.to} 301`);
|
|
14
|
+
});
|
|
15
|
+
if (lines.length > maxStaticRules)
|
|
16
|
+
throw new VageError(`_redirects would have ${lines.length} rules; Cloudflare Pages allows ${maxStaticRules}.`);
|
|
17
|
+
return `# Generated by VAGE JS from redirects in vage.config.ts\n${lines.join("\n")}\n`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** File names that commonly hold credentials. */
|
|
4
|
+
const SECRET_FILES = [
|
|
5
|
+
[/(^|\/)\.env(\.(?!example$|sample$|template$)[^/]+)?$/, "environment file"],
|
|
6
|
+
[/(^|\/)\.(dev\.vars|envrc)$/, "environment file"],
|
|
7
|
+
[/(^|\/)(google|api-keys)\.json$/, "VAGE JS credentials file"],
|
|
8
|
+
[/\.(pem|key|p12|pfx|jks|keystore)$/i, "private key or certificate store"],
|
|
9
|
+
[/(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/, "SSH private key"],
|
|
10
|
+
[/(^|\/)(credentials|client_secrets?|service[-_]?account[^/]*)\.json$/i, "credentials file"],
|
|
11
|
+
[/(^|\/)\.(netrc|pgpass)$/, "credentials file"],
|
|
12
|
+
];
|
|
13
|
+
/** Well-known token formats. Matching is a strong hint, not proof; false positives are possible. */
|
|
14
|
+
const SECRET_CONTENT = [
|
|
15
|
+
[/-----BEGIN (?:[A-Z]+ )?PRIVATE KEY(?: BLOCK)?-----/, "private key"],
|
|
16
|
+
[/\bAKIA[0-9A-Z]{16}\b/, "AWS access key ID"],
|
|
17
|
+
[/\bgh[pousr]_[A-Za-z0-9]{36,}\b/, "GitHub token"],
|
|
18
|
+
[/\bgithub_pat_[A-Za-z0-9_]{22,}\b/, "GitHub fine-grained token"],
|
|
19
|
+
[/\bAIza[0-9A-Za-z_-]{35}\b/, "Google API key"],
|
|
20
|
+
[/\bGOCSPX-[A-Za-z0-9_-]{20,}/, "Google OAuth client secret"],
|
|
21
|
+
[/\b1\/\/0[A-Za-z0-9_-]{20,}/, "Google OAuth refresh token"],
|
|
22
|
+
[/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, "Slack token"],
|
|
23
|
+
[/\bsk_live_[A-Za-z0-9]{16,}\b/, "Stripe live key"],
|
|
24
|
+
[/\bsk-ant-[A-Za-z0-9_-]{20,}\b/, "Anthropic API key"],
|
|
25
|
+
[/\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/, "OpenAI-style API key"],
|
|
26
|
+
[/_authToken\s*=\s*\S+/, "npm auth token"],
|
|
27
|
+
];
|
|
28
|
+
/** Text files above this are flagged for manual review instead of being skipped silently. */
|
|
29
|
+
const MAX_SCAN_BYTES = 20 * 1024 * 1024;
|
|
30
|
+
/** Scans files about to be committed. Never returns the secret value itself. */
|
|
31
|
+
export function findSecrets(root, files) {
|
|
32
|
+
const hits = [];
|
|
33
|
+
for (const rel of files) {
|
|
34
|
+
const name = SECRET_FILES.find(([re]) => re.test(rel));
|
|
35
|
+
if (name) {
|
|
36
|
+
hits.push({ path: rel, reason: name[1] });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const file = path.join(root, rel);
|
|
40
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
41
|
+
continue;
|
|
42
|
+
if (isBinary(file))
|
|
43
|
+
continue;
|
|
44
|
+
if (fs.statSync(file).size > MAX_SCAN_BYTES) {
|
|
45
|
+
hits.push({ path: rel, reason: "text file too large to scan for secrets; review it manually" });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const buf = fs.readFileSync(file);
|
|
49
|
+
const text = buf.toString("utf8");
|
|
50
|
+
const content = SECRET_CONTENT.find(([re]) => re.test(text));
|
|
51
|
+
if (content)
|
|
52
|
+
hits.push({ path: rel, reason: `looks like a ${content[1]}` });
|
|
53
|
+
}
|
|
54
|
+
return hits;
|
|
55
|
+
}
|
|
56
|
+
function isBinary(file) {
|
|
57
|
+
const fd = fs.openSync(file, "r");
|
|
58
|
+
try {
|
|
59
|
+
const head = Buffer.alloc(8000);
|
|
60
|
+
const n = fs.readSync(fd, head, 0, head.length, 0);
|
|
61
|
+
return head.subarray(0, n).includes(0);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
fs.closeSync(fd);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
/**
|
|
3
|
+
* Runs before Vite's own middleware. The dev server only needs to serve
|
|
4
|
+
* rendered pages, discovery files, public/ files and Vite's live-reload
|
|
5
|
+
* client, so everything else (package.json, vage.config.ts, page sources,
|
|
6
|
+
* layouts, /@fs/ paths, ?raw/?import) is a 404. This matters with --host.
|
|
7
|
+
*/
|
|
8
|
+
export declare function devAccessGuard(root: string, allowedFiles: Set<string>): Plugin;
|
|
9
|
+
export declare function allowed(url: string, publicDir: string, allowedFiles: Set<string>): boolean;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** Vite's source-transform endpoints: they would expose project files as text or modules. */
|
|
4
|
+
const SOURCE_QUERY = /[?&](raw|import|url|inline|worker|sharedworker|direct|html-proxy)\b/;
|
|
5
|
+
const VITE_CLIENT = "/node_modules/vite/dist/client/";
|
|
6
|
+
/**
|
|
7
|
+
* Runs before Vite's own middleware. The dev server only needs to serve
|
|
8
|
+
* rendered pages, discovery files, public/ files and Vite's live-reload
|
|
9
|
+
* client, so everything else (package.json, vage.config.ts, page sources,
|
|
10
|
+
* layouts, /@fs/ paths, ?raw/?import) is a 404. This matters with --host.
|
|
11
|
+
*/
|
|
12
|
+
export function devAccessGuard(root, allowedFiles) {
|
|
13
|
+
const publicDir = path.join(root, "public");
|
|
14
|
+
return {
|
|
15
|
+
name: "vage:dev-access-guard",
|
|
16
|
+
configureServer(server) {
|
|
17
|
+
server.middlewares.use((req, res, next) => {
|
|
18
|
+
if (allowed(req.url ?? "/", publicDir, allowedFiles))
|
|
19
|
+
return next();
|
|
20
|
+
res.statusCode = 404;
|
|
21
|
+
res.end("Not found");
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function allowed(url, publicDir, allowedFiles) {
|
|
27
|
+
const [rawPath = "/", query = ""] = url.split("?");
|
|
28
|
+
let pathname;
|
|
29
|
+
try {
|
|
30
|
+
pathname = decodeURIComponent(rawPath);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (pathname === "/@vite/client" || pathname === "/@vite/env" || pathname.startsWith("/__vite"))
|
|
36
|
+
return true;
|
|
37
|
+
if (pathname.startsWith(VITE_CLIENT) || (pathname.startsWith("/@fs/") && pathname.includes(VITE_CLIENT)))
|
|
38
|
+
return !pathname.includes("..");
|
|
39
|
+
if (SOURCE_QUERY.test(`?${query}`))
|
|
40
|
+
return false;
|
|
41
|
+
if (pathname.startsWith("/@"))
|
|
42
|
+
return false; // /@fs/, /@id/ and other internals
|
|
43
|
+
const ext = path.posix.extname(pathname);
|
|
44
|
+
if (!ext || ext === ".html")
|
|
45
|
+
return true; // rendered pages (our middleware decides 200/404)
|
|
46
|
+
if (allowedFiles.has(pathname))
|
|
47
|
+
return true;
|
|
48
|
+
const file = path.resolve(publicDir, `.${pathname}`);
|
|
49
|
+
if (!file.startsWith(publicDir + path.sep))
|
|
50
|
+
return false;
|
|
51
|
+
try {
|
|
52
|
+
return fs.lstatSync(file).isFile(); // lstat: symlinks are not served
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface ServeOptions {
|
|
2
|
+
port?: number;
|
|
3
|
+
host?: string | boolean;
|
|
4
|
+
}
|
|
5
|
+
/** Runs a Vite listen() call and turns a port conflict into an actionable message. */
|
|
6
|
+
export declare function listenOrExplain<T>(listen: () => Promise<T>, port: number | undefined): Promise<T>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { VageError } from "../core/errors.js";
|
|
2
|
+
/** Runs a Vite listen() call and turns a port conflict into an actionable message. */
|
|
3
|
+
export async function listenOrExplain(listen, port) {
|
|
4
|
+
try {
|
|
5
|
+
return await listen();
|
|
6
|
+
}
|
|
7
|
+
catch (err) {
|
|
8
|
+
if (/already in use|EADDRINUSE/i.test(err.message)) {
|
|
9
|
+
throw new VageError(`Port ${port} is already in use. Stop the process using it, or choose another port with --port <number>.`);
|
|
10
|
+
}
|
|
11
|
+
throw err;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
import { type PreviewServer } from "vite";
|
|
3
|
+
import { type ServeOptions } from "./listen.js";
|
|
4
|
+
/** Serves the production build output from dist/. */
|
|
5
|
+
export declare function startPreviewServer(root: string, options?: ServeOptions): Promise<PreviewServer>;
|
|
6
|
+
export declare function redirect(res: ServerResponse, location: string): void;
|