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,9 @@
|
|
|
1
|
+
/** An expected, user-facing error. The CLI prints its message without a stack trace. */
|
|
2
|
+
export class VageError extends Error {
|
|
3
|
+
file;
|
|
4
|
+
constructor(message, file) {
|
|
5
|
+
super(file ? `${message}\n at ${file}` : message);
|
|
6
|
+
this.file = file;
|
|
7
|
+
this.name = "VageError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const ESCAPES = {
|
|
2
|
+
"&": "&",
|
|
3
|
+
"<": "<",
|
|
4
|
+
">": ">",
|
|
5
|
+
'"': """,
|
|
6
|
+
"'": "'",
|
|
7
|
+
};
|
|
8
|
+
/** Escapes text for safe use in HTML content and attribute values. */
|
|
9
|
+
export function escapeHtml(value) {
|
|
10
|
+
return String(value ?? "").replace(/[&<>"']/g, (c) => ESCAPES[c]);
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type InlineConfig, type ViteDevServer } from "vite";
|
|
2
|
+
import type { ModuleLoader } from "./config.js";
|
|
3
|
+
/** Shared Vite options. configFile: false keeps a project's own vite.config out of the picture. */
|
|
4
|
+
export declare function baseViteConfig(root: string): InlineConfig;
|
|
5
|
+
export declare function viteLoader(server: ViteDevServer): ModuleLoader;
|
|
6
|
+
/** A headless Vite server used only to import TypeScript config and layout files during a build. */
|
|
7
|
+
export declare function createBuildLoader(root: string): Promise<{
|
|
8
|
+
load: ModuleLoader;
|
|
9
|
+
close: () => Promise<void>;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createServer } from "vite";
|
|
2
|
+
/** Shared Vite options. configFile: false keeps a project's own vite.config out of the picture. */
|
|
3
|
+
export function baseViteConfig(root) {
|
|
4
|
+
return { root, configFile: false, logLevel: "warn", appType: "custom" };
|
|
5
|
+
}
|
|
6
|
+
export function viteLoader(server) {
|
|
7
|
+
return (file) => server.ssrLoadModule(file);
|
|
8
|
+
}
|
|
9
|
+
/** A headless Vite server used only to import TypeScript config and layout files during a build. */
|
|
10
|
+
export async function createBuildLoader(root) {
|
|
11
|
+
const server = await createServer({
|
|
12
|
+
...baseViteConfig(root),
|
|
13
|
+
server: { middlewareMode: true, hmr: false, ws: false, watch: null },
|
|
14
|
+
});
|
|
15
|
+
return { load: viteLoader(server), close: () => server.close() };
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { findConfigFile, loadConfig, parseConfig } from "./config.js";
|
|
2
|
+
import { createBuildLoader } from "./loader.js";
|
|
3
|
+
/** Loads vage.config.* outside a build (defaults when there is none). */
|
|
4
|
+
export async function loadProjectConfig(root) {
|
|
5
|
+
if (!findConfigFile(root))
|
|
6
|
+
return parseConfig({});
|
|
7
|
+
const loader = await createBuildLoader(root);
|
|
8
|
+
try {
|
|
9
|
+
return await loadConfig(root, loader.load);
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
await loader.close();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Frontmatter } from "../content/markdown.js";
|
|
2
|
+
import type { ModuleLoader, SiteConfig } from "./config.js";
|
|
3
|
+
import { type PageMeta } from "../seo/head.js";
|
|
4
|
+
export interface LayoutProps {
|
|
5
|
+
/** Rendered page body HTML. Insert as-is; do not escape. */
|
|
6
|
+
content: string;
|
|
7
|
+
page: {
|
|
8
|
+
route: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Absolute canonical URL, when `site` is set and the page is indexable. */
|
|
12
|
+
url?: string;
|
|
13
|
+
frontmatter: Frontmatter;
|
|
14
|
+
};
|
|
15
|
+
site: SiteConfig;
|
|
16
|
+
}
|
|
17
|
+
export type Layout = (props: LayoutProps) => string | Promise<string>;
|
|
18
|
+
export interface RenderedPage {
|
|
19
|
+
html: string;
|
|
20
|
+
frontmatter: Frontmatter;
|
|
21
|
+
/** Markdown body without frontmatter. */
|
|
22
|
+
body: string;
|
|
23
|
+
meta: PageMeta;
|
|
24
|
+
}
|
|
25
|
+
export declare function renderPage(options: {
|
|
26
|
+
root: string;
|
|
27
|
+
file: string;
|
|
28
|
+
route: string;
|
|
29
|
+
site: SiteConfig;
|
|
30
|
+
load: ModuleLoader;
|
|
31
|
+
/** Renders the 404 page: always noindex, no canonical. */
|
|
32
|
+
notFound?: boolean;
|
|
33
|
+
/** Add configured analytics tags. Off in the dev server so development traffic is never measured. */
|
|
34
|
+
analytics?: boolean;
|
|
35
|
+
}): Promise<RenderedPage>;
|
|
36
|
+
/** Used when a project has no src/layouts/default.ts. */
|
|
37
|
+
export declare const defaultLayout: Layout;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseMarkdown } from "../content/markdown.js";
|
|
4
|
+
import { sanitizeHtml } from "../content/sanitize.js";
|
|
5
|
+
import { VageError } from "./errors.js";
|
|
6
|
+
import { escapeHtml } from "./html.js";
|
|
7
|
+
import { headTags, injectHead, pageMeta } from "../seo/head.js";
|
|
8
|
+
import { analyticsTags, injectAnalytics } from "../integrations/tags.js";
|
|
9
|
+
export async function renderPage(options) {
|
|
10
|
+
const { root, file, route, site, load } = options;
|
|
11
|
+
const parsed = parseMarkdown(fs.readFileSync(file, "utf8"), file);
|
|
12
|
+
const { frontmatter, body } = parsed;
|
|
13
|
+
const content = site.markdown.html === "allow" ? parsed.html : await sanitizeHtml(parsed.html);
|
|
14
|
+
const meta = pageMeta(site, route, frontmatter, options.notFound);
|
|
15
|
+
const layout = await resolveLayout(root, frontmatter.layout, load, file);
|
|
16
|
+
const html = await layout({
|
|
17
|
+
content,
|
|
18
|
+
page: { route, title: frontmatter.title, description: frontmatter.description, url: meta.canonical, frontmatter },
|
|
19
|
+
site,
|
|
20
|
+
});
|
|
21
|
+
if (typeof html !== "string") {
|
|
22
|
+
throw new VageError(`Layout "${frontmatter.layout ?? "default"}" must return an HTML string.`, file);
|
|
23
|
+
}
|
|
24
|
+
let out = injectHead(html, headTags(site, meta), file);
|
|
25
|
+
const tags = options.analytics ? analyticsTags(site) : undefined;
|
|
26
|
+
if (tags)
|
|
27
|
+
out = injectAnalytics(out, tags, file);
|
|
28
|
+
return { html: out, frontmatter, body, meta };
|
|
29
|
+
}
|
|
30
|
+
const LAYOUT_EXTENSIONS = [".ts", ".js", ".mjs"];
|
|
31
|
+
async function resolveLayout(root, name, load, pageFile) {
|
|
32
|
+
const base = path.join(root, "src", "layouts", name ?? "default");
|
|
33
|
+
const file = LAYOUT_EXTENSIONS.map((ext) => base + ext).find((f) => fs.existsSync(f));
|
|
34
|
+
if (!file) {
|
|
35
|
+
if (!name)
|
|
36
|
+
return defaultLayout;
|
|
37
|
+
throw new VageError(`Layout "${name}" not found. Expected src/layouts/${name}.ts`, pageFile);
|
|
38
|
+
}
|
|
39
|
+
const layout = (await load(file)).default;
|
|
40
|
+
if (typeof layout !== "function") {
|
|
41
|
+
throw new VageError("Layout files must default-export a function that returns an HTML string.", file);
|
|
42
|
+
}
|
|
43
|
+
return layout;
|
|
44
|
+
}
|
|
45
|
+
/** Used when a project has no src/layouts/default.ts. */
|
|
46
|
+
export const defaultLayout = ({ content, page, site }) => {
|
|
47
|
+
const title = site.title && site.title !== page.title ? `${page.title} | ${site.title}` : page.title;
|
|
48
|
+
return `<!doctype html>
|
|
49
|
+
<html lang="${escapeHtml(site.lang)}">
|
|
50
|
+
<head>
|
|
51
|
+
<meta charset="utf-8" />
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
53
|
+
<title>${escapeHtml(title)}</title>
|
|
54
|
+
</head>
|
|
55
|
+
<body>
|
|
56
|
+
<main>
|
|
57
|
+
${content}
|
|
58
|
+
</main>
|
|
59
|
+
</body>
|
|
60
|
+
</html>
|
|
61
|
+
`;
|
|
62
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface RouteEntry {
|
|
2
|
+
/** URL path, always with a trailing slash, e.g. "/blog/first-post/". */
|
|
3
|
+
route: string;
|
|
4
|
+
/** Absolute path of the source file. */
|
|
5
|
+
file: string;
|
|
6
|
+
/** Output path relative to the build directory, e.g. "blog/first-post/index.html". */
|
|
7
|
+
outFile: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const NOT_FOUND_PAGE = "404.md";
|
|
10
|
+
/**
|
|
11
|
+
* Maps src/pages/**\/*.md to routes:
|
|
12
|
+
* index.md -> /
|
|
13
|
+
* about.md -> /about/
|
|
14
|
+
* blog/index.md -> /blog/
|
|
15
|
+
* blog/post.md -> /blog/post/
|
|
16
|
+
* Files and directories starting with "_" are ignored. The top-level 404.md is
|
|
17
|
+
* not a route; use findNotFoundPage() for it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function discoverRoutes(pagesDir: string): RouteEntry[];
|
|
20
|
+
/** Route for a page path relative to src/pages (works for deleted files too). */
|
|
21
|
+
export declare function routeForPage(rel: string): string | undefined;
|
|
22
|
+
export declare function findNotFoundPage(pagesDir: string): string | undefined;
|
|
23
|
+
/** Normalizes a request path ("/about", "/about/index.html") to a route key ("/about/"). */
|
|
24
|
+
export declare function normalizeRequestPath(urlPath: string): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { VageError } from "./errors.js";
|
|
4
|
+
export const NOT_FOUND_PAGE = "404.md";
|
|
5
|
+
/**
|
|
6
|
+
* Maps src/pages/**\/*.md to routes:
|
|
7
|
+
* index.md -> /
|
|
8
|
+
* about.md -> /about/
|
|
9
|
+
* blog/index.md -> /blog/
|
|
10
|
+
* blog/post.md -> /blog/post/
|
|
11
|
+
* Files and directories starting with "_" are ignored. The top-level 404.md is
|
|
12
|
+
* not a route; use findNotFoundPage() for it.
|
|
13
|
+
*/
|
|
14
|
+
export function discoverRoutes(pagesDir) {
|
|
15
|
+
if (!fs.existsSync(pagesDir))
|
|
16
|
+
return [];
|
|
17
|
+
const routes = new Map();
|
|
18
|
+
for (const rel of walk(pagesDir)) {
|
|
19
|
+
if (rel === NOT_FOUND_PAGE)
|
|
20
|
+
continue;
|
|
21
|
+
const segments = routeSegments(rel);
|
|
22
|
+
const route = segments.length ? `/${segments.join("/")}/` : "/";
|
|
23
|
+
const file = path.join(pagesDir, rel);
|
|
24
|
+
const existing = routes.get(route);
|
|
25
|
+
if (existing) {
|
|
26
|
+
throw new VageError(`Two pages map to the route "${route}": ${path.relative(pagesDir, existing.file)} and ${rel}. Remove or rename one of them.`, file);
|
|
27
|
+
}
|
|
28
|
+
routes.set(route, { route, file, outFile: path.posix.join(...segments, "index.html") });
|
|
29
|
+
}
|
|
30
|
+
return [...routes.values()].sort((a, b) => a.route.localeCompare(b.route));
|
|
31
|
+
}
|
|
32
|
+
function routeSegments(rel) {
|
|
33
|
+
const segments = rel.slice(0, -".md".length).split("/");
|
|
34
|
+
if (segments.at(-1) === "index")
|
|
35
|
+
segments.pop();
|
|
36
|
+
return segments;
|
|
37
|
+
}
|
|
38
|
+
/** Route for a page path relative to src/pages (works for deleted files too). */
|
|
39
|
+
export function routeForPage(rel) {
|
|
40
|
+
if (!rel.endsWith(".md") || rel === NOT_FOUND_PAGE || rel.split("/").some((s) => s.startsWith("_") || s.startsWith(".")))
|
|
41
|
+
return undefined;
|
|
42
|
+
const segments = routeSegments(rel);
|
|
43
|
+
return segments.length ? `/${segments.join("/")}/` : "/";
|
|
44
|
+
}
|
|
45
|
+
export function findNotFoundPage(pagesDir) {
|
|
46
|
+
const file = path.join(pagesDir, NOT_FOUND_PAGE);
|
|
47
|
+
return fs.existsSync(file) ? file : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Normalizes a request path ("/about", "/about/index.html") to a route key ("/about/"). */
|
|
50
|
+
export function normalizeRequestPath(urlPath) {
|
|
51
|
+
let p = decodeURIComponent(urlPath.split(/[?#]/)[0] ?? "/");
|
|
52
|
+
if (p.endsWith("/index.html"))
|
|
53
|
+
p = p.slice(0, -"index.html".length);
|
|
54
|
+
else if (p.endsWith(".html"))
|
|
55
|
+
p = p.slice(0, -".html".length);
|
|
56
|
+
return p.endsWith("/") ? p : `${p}/`;
|
|
57
|
+
}
|
|
58
|
+
function walk(dir, prefix = "") {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
61
|
+
if (entry.name.startsWith("_") || entry.name.startsWith("."))
|
|
62
|
+
continue;
|
|
63
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
64
|
+
if (entry.isDirectory())
|
|
65
|
+
out.push(...walk(path.join(dir, entry.name), rel));
|
|
66
|
+
else if (entry.isFile() && entry.name.endsWith(".md"))
|
|
67
|
+
out.push(rel);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ModuleLoader, type SiteConfig } from "./config.js";
|
|
2
|
+
import { type RenderedPage } from "./render.js";
|
|
3
|
+
export interface SitePage extends RenderedPage {
|
|
4
|
+
route: string;
|
|
5
|
+
/** Source path relative to the project root. */
|
|
6
|
+
source: string;
|
|
7
|
+
outFile: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SiteRedirect {
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
outFile: string;
|
|
13
|
+
html: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SiteModel {
|
|
16
|
+
site: SiteConfig;
|
|
17
|
+
/** Published pages (drafts excluded). */
|
|
18
|
+
pages: SitePage[];
|
|
19
|
+
skippedDrafts: string[];
|
|
20
|
+
/** Routes of draft pages; links to them are broken in production. */
|
|
21
|
+
draftRoutes: string[];
|
|
22
|
+
notFound?: SitePage;
|
|
23
|
+
redirects: SiteRedirect[];
|
|
24
|
+
/** Generated discovery files: output path -> content. */
|
|
25
|
+
files: Map<string, string>;
|
|
26
|
+
warnings: string[];
|
|
27
|
+
}
|
|
28
|
+
/** Renders every page and derives discovery files from the same data, without writing anything. */
|
|
29
|
+
export declare function renderSite(root: string, load: ModuleLoader): Promise<SiteModel>;
|
|
30
|
+
/** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
|
|
31
|
+
export declare function listedPages(model: SiteModel): SitePage[];
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { llmsFullTxt, llmsTxt } from "../discovery/llms.js";
|
|
4
|
+
import { robotsTxt } from "../discovery/robots.js";
|
|
5
|
+
import { SITEMAP_URL_LIMIT, sitemapXml } from "../discovery/sitemap.js";
|
|
6
|
+
import { headersFile } from "../deploy/headers.js";
|
|
7
|
+
import { cloudflareRedirects } from "../deploy/redirects.js";
|
|
8
|
+
import { findSecrets } from "../deploy/secrets.js";
|
|
9
|
+
import { rulePack } from "../guidelines/pack.js";
|
|
10
|
+
import { absoluteUrl } from "../seo/urls.js";
|
|
11
|
+
import { loadConfig } from "./config.js";
|
|
12
|
+
import { VageError } from "./errors.js";
|
|
13
|
+
import { escapeHtml } from "./html.js";
|
|
14
|
+
import { renderPage } from "./render.js";
|
|
15
|
+
import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "./routes.js";
|
|
16
|
+
/** Renders every page and derives discovery files from the same data, without writing anything. */
|
|
17
|
+
export async function renderSite(root, load) {
|
|
18
|
+
const pagesDir = path.join(root, "src", "pages");
|
|
19
|
+
if (!fs.existsSync(pagesDir)) {
|
|
20
|
+
throw new VageError(`No src/pages directory found in ${root}. Is this a VAGE JS project?`);
|
|
21
|
+
}
|
|
22
|
+
const site = await loadConfig(root, load);
|
|
23
|
+
const pinned = site.guidelines.rulePack;
|
|
24
|
+
if (pinned && pinned !== rulePack.version) {
|
|
25
|
+
throw new VageError(`This project pins rule pack ${pinned}, but the installed vagejs ships ${rulePack.version}. ` +
|
|
26
|
+
`Review what changed with "vage guidelines", then update guidelines.rulePack in vage.config.ts.`);
|
|
27
|
+
}
|
|
28
|
+
const model = { site, pages: [], skippedDrafts: [], draftRoutes: [], redirects: [], files: new Map(), warnings: [] };
|
|
29
|
+
for (const entry of discoverRoutes(pagesDir)) {
|
|
30
|
+
const page = await renderPage({ root, file: entry.file, route: entry.route, site, load, analytics: true });
|
|
31
|
+
const source = path.relative(root, entry.file);
|
|
32
|
+
if (page.frontmatter.draft) {
|
|
33
|
+
model.skippedDrafts.push(source);
|
|
34
|
+
model.draftRoutes.push(entry.route);
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
model.pages.push({ ...page, route: entry.route, source, outFile: entry.outFile });
|
|
38
|
+
}
|
|
39
|
+
const notFound = findNotFoundPage(pagesDir);
|
|
40
|
+
if (notFound) {
|
|
41
|
+
const page = await renderPage({ root, file: notFound, route: "/404/", site, load, notFound: true, analytics: true });
|
|
42
|
+
model.notFound = { ...page, route: "/404/", source: path.relative(root, notFound), outFile: "404.html" };
|
|
43
|
+
}
|
|
44
|
+
checkPublicDir(root, model.warnings);
|
|
45
|
+
model.redirects = buildRedirects(site, new Set(model.pages.map((p) => p.route)));
|
|
46
|
+
addDiscoveryFiles(root, model);
|
|
47
|
+
return model;
|
|
48
|
+
}
|
|
49
|
+
/** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
|
|
50
|
+
export function listedPages(model) {
|
|
51
|
+
return model.pages.filter((p) => !p.meta.noindex && p.meta.canonical === absoluteUrl(model.site.site, p.route));
|
|
52
|
+
}
|
|
53
|
+
function addDiscoveryFiles(root, model) {
|
|
54
|
+
const { site, files, warnings } = model;
|
|
55
|
+
const listed = listedPages(model);
|
|
56
|
+
const generate = (name, content) => {
|
|
57
|
+
if (fs.existsSync(path.join(root, "public", name))) {
|
|
58
|
+
throw new VageError(`public/${name} conflicts with the generated ${name}. Delete it, or turn generation off in vage.config.ts (seo).`);
|
|
59
|
+
}
|
|
60
|
+
files.set(name, content());
|
|
61
|
+
};
|
|
62
|
+
if (site.site && /^https?:\/\/(www\.)?example\.(com|org|net)$/.test(site.site)) {
|
|
63
|
+
warnings.push(`"site" is still the placeholder ${site.site}; canonical URLs and sitemap.xml will point there. Set your real origin.`);
|
|
64
|
+
}
|
|
65
|
+
if (!site.site) {
|
|
66
|
+
warnings.push('"site" is not set in vage.config.ts: canonical URLs, og:url, sitemap.xml and absolute image URLs are skipped.');
|
|
67
|
+
}
|
|
68
|
+
if (site.seo.robots) {
|
|
69
|
+
const robots = site.seo.robots;
|
|
70
|
+
generate("robots.txt", () => robotsTxt(site, robots));
|
|
71
|
+
}
|
|
72
|
+
if (site.seo.sitemap && site.site) {
|
|
73
|
+
if (listed.length > SITEMAP_URL_LIMIT) {
|
|
74
|
+
throw new VageError(`sitemap.xml would list ${listed.length} URLs; sitemap indexes (over ${SITEMAP_URL_LIMIT}) are not supported yet.`);
|
|
75
|
+
}
|
|
76
|
+
generate("sitemap.xml", () => sitemapXml(listed.map((p) => ({ url: p.meta.canonical, lastmod: p.frontmatter.dateModified ?? p.frontmatter.datePublished }))));
|
|
77
|
+
}
|
|
78
|
+
const indexNow = site.integrations.indexNow;
|
|
79
|
+
if (indexNow)
|
|
80
|
+
generate(`${indexNow.key}.txt`, () => `${indexNow.key}\n`);
|
|
81
|
+
if (site.deploy.target === "cloudflare-pages" && model.redirects.length)
|
|
82
|
+
generate("_redirects", () => cloudflareRedirects(model.redirects));
|
|
83
|
+
if (site.deploy.target === "cloudflare-pages" && site.security.headers) {
|
|
84
|
+
const html = [...model.pages, ...(model.notFound ? [model.notFound] : []), ...model.redirects].map((p) => p.html);
|
|
85
|
+
generate("_headers", () => headersFile(html, site));
|
|
86
|
+
}
|
|
87
|
+
if (site.seo.llmsTxt) {
|
|
88
|
+
if (!site.title) {
|
|
89
|
+
warnings.push('"title" is not set in vage.config.ts: llms.txt needs a site title and was skipped.');
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const pages = listed.map((p) => ({
|
|
93
|
+
title: p.frontmatter.title,
|
|
94
|
+
url: absoluteUrl(site.site, p.route) ?? p.route,
|
|
95
|
+
description: p.frontmatter.description,
|
|
96
|
+
body: p.body,
|
|
97
|
+
}));
|
|
98
|
+
generate("llms.txt", () => llmsTxt(site.title, site.description, pages));
|
|
99
|
+
if (site.seo.llmsTxt.full)
|
|
100
|
+
generate("llms-full.txt", () => llmsFullTxt(site.title, site.description, pages));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Everything in public/ is published. Refuses symlinks (they can point at files
|
|
106
|
+
* outside the project) and files that look like secrets. Google browser API keys
|
|
107
|
+
* are meant to be public when referrer-restricted, so they only warn.
|
|
108
|
+
*/
|
|
109
|
+
function checkPublicDir(root, warnings) {
|
|
110
|
+
const publicDir = path.join(root, "public");
|
|
111
|
+
if (!fs.existsSync(publicDir))
|
|
112
|
+
return;
|
|
113
|
+
const files = [];
|
|
114
|
+
const walk = (dir, prefix) => {
|
|
115
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
116
|
+
const rel = `${prefix}${e.name}`;
|
|
117
|
+
if (e.isSymbolicLink()) {
|
|
118
|
+
throw new VageError(`public/${rel} is a symlink. Copy the real file into public/ instead; links can expose files outside the project.`);
|
|
119
|
+
}
|
|
120
|
+
if (e.isDirectory())
|
|
121
|
+
walk(path.join(dir, e.name), `${rel}/`);
|
|
122
|
+
else
|
|
123
|
+
files.push(rel);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
walk(publicDir, "");
|
|
127
|
+
const hits = findSecrets(publicDir, files);
|
|
128
|
+
const blocking = hits.filter((h) => h.reason !== "looks like a Google API key");
|
|
129
|
+
for (const h of hits.filter((h) => !blocking.includes(h))) {
|
|
130
|
+
warnings.push(`public/${h.path} contains a Google API key and will be public. Fine for a referrer-restricted browser key; otherwise remove it.`);
|
|
131
|
+
}
|
|
132
|
+
if (blocking.length) {
|
|
133
|
+
throw new VageError(`Refusing to publish possible secrets from public/:\n${blocking.map((h) => ` public/${h.path}: ${h.reason}`).join("\n")}\nEverything in public/ is served to the world. Move these files out (and rotate exposed credentials).`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function buildRedirects(site, routes) {
|
|
137
|
+
return Object.entries(site.redirects).map(([fromPath, to]) => {
|
|
138
|
+
const from = normalizeRequestPath(fromPath);
|
|
139
|
+
if (routes.has(from)) {
|
|
140
|
+
throw new VageError(`Redirect source "${fromPath}" is also a page route. Remove the page or the redirect.`);
|
|
141
|
+
}
|
|
142
|
+
const target = absoluteUrl(site.site, to) ?? to;
|
|
143
|
+
const html = `<!doctype html>
|
|
144
|
+
<html>
|
|
145
|
+
<head>
|
|
146
|
+
<meta charset="utf-8" />
|
|
147
|
+
<title>Redirecting…</title>
|
|
148
|
+
<meta name="robots" content="noindex" />
|
|
149
|
+
<link rel="canonical" href="${escapeHtml(target)}" />
|
|
150
|
+
<meta http-equiv="refresh" content="0; url=${escapeHtml(to)}" />
|
|
151
|
+
</head>
|
|
152
|
+
<body>
|
|
153
|
+
<p>This page has moved to <a href="${escapeHtml(to)}">${escapeHtml(to)}</a>.</p>
|
|
154
|
+
</body>
|
|
155
|
+
</html>
|
|
156
|
+
`;
|
|
157
|
+
return { from, to, outFile: path.posix.join(from.slice(1), "index.html"), html };
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validators for values that reach other programs (git, wrangler, npm) as
|
|
3
|
+
* arguments. A leading "-" would be read as an option, so it is always refused.
|
|
4
|
+
*/
|
|
5
|
+
export declare function assertBranchName(value: string, flag?: string): string;
|
|
6
|
+
export declare function assertCloudflareProject(value: string): string;
|
|
7
|
+
/** https://, ssh://, git@host:path, or a local path. Refuses options and git's command-running transports. */
|
|
8
|
+
export declare function assertGitRemote(value: string): string;
|
|
9
|
+
/** npm dependency spec for new projects: a version/range, "latest", or a local file: tarball. */
|
|
10
|
+
export declare function assertVageSpec(value: string): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { VageError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validators for values that reach other programs (git, wrangler, npm) as
|
|
4
|
+
* arguments. A leading "-" would be read as an option, so it is always refused.
|
|
5
|
+
*/
|
|
6
|
+
export function assertBranchName(value, flag = "--branch") {
|
|
7
|
+
if (!/^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]{1,200}$/.test(value) || value.endsWith("/") || value.endsWith(".lock")) {
|
|
8
|
+
throw new VageError(`Invalid ${flag} "${value}". Use letters, digits, ".", "_", "-" and "/" (not starting with "-").`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export function assertCloudflareProject(value) {
|
|
13
|
+
if (!/^[a-z0-9][a-z0-9-]{0,57}$/.test(value))
|
|
14
|
+
throw new VageError(`Invalid --project "${value}". Use lowercase letters, digits and "-".`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
/** https://, ssh://, git@host:path, or a local path. Refuses options and git's command-running transports. */
|
|
18
|
+
export function assertGitRemote(value) {
|
|
19
|
+
if (value.startsWith("-") || /^(ext|fd)::/i.test(value) || /[\s\u0000-\u001f]/.test(value)) {
|
|
20
|
+
throw new VageError(`Invalid --remote "${value}". Use an https://, ssh:// or git@host:owner/repo.git URL.`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/** npm dependency spec for new projects: a version/range, "latest", or a local file: tarball. */
|
|
25
|
+
export function assertVageSpec(value) {
|
|
26
|
+
if (!/^(latest|[~^]?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?|file:[^\s]+)$/.test(value)) {
|
|
27
|
+
throw new VageError(`Invalid --vage-spec "${value}". Use a version like ^0.1.0, "latest", or file:<path-to-tarball>.`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DeployStatus, Runner } from "./github-pages.js";
|
|
2
|
+
/** Pinned so deploys are reproducible; checked 2026-10-23. */
|
|
3
|
+
export declare const WRANGLER = "wrangler@4.152.0";
|
|
4
|
+
/**
|
|
5
|
+
* Uploads dist/ with `wrangler pages deploy`. Auth comes from the environment
|
|
6
|
+
* (CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID) or a prior `wrangler login`.
|
|
7
|
+
* Reports success only when wrangler succeeds AND the deployment URL serves the site.
|
|
8
|
+
*/
|
|
9
|
+
export declare function deployCloudflarePages(root: string, options: {
|
|
10
|
+
project: string;
|
|
11
|
+
branch: string;
|
|
12
|
+
commit?: string;
|
|
13
|
+
run?: Runner;
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
sleep?: (ms: number) => Promise<void>;
|
|
16
|
+
env?: Record<string, string | undefined>;
|
|
17
|
+
}): Promise<DeployStatus & {
|
|
18
|
+
deploymentUrl?: string;
|
|
19
|
+
branch: string;
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
: "";
|
|
24
|
+
return { status: "failure", branch: options.branch, reason: `wrangler failed (exit ${res.code}).${auth}\n${tail(output)}` };
|
|
25
|
+
}
|
|
26
|
+
const deploymentUrl = output.match(/https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.pages\.dev/gi)?.at(-1);
|
|
27
|
+
if (!deploymentUrl)
|
|
28
|
+
return { status: "unconfirmed", branch: options.branch, reason: `wrangler succeeded but printed no deployment URL.\n${tail(output)}` };
|
|
29
|
+
// Wrangler's success is the provider's confirmation; also check the deployment actually serves.
|
|
30
|
+
for (let i = 0; i < 5; i++) {
|
|
31
|
+
const page = await doFetch(deploymentUrl, { signal: AbortSignal.timeout(20_000) }).catch(() => undefined);
|
|
32
|
+
if (page?.ok)
|
|
33
|
+
return { status: "success", branch: options.branch, deploymentUrl, pageUrl: deploymentUrl };
|
|
34
|
+
await sleep(3000);
|
|
35
|
+
}
|
|
36
|
+
return { status: "unconfirmed", branch: options.branch, deploymentUrl, reason: `Uploaded, but ${deploymentUrl} did not respond with 200 yet. Check it in a minute.` };
|
|
37
|
+
}
|
|
38
|
+
function tail(text) {
|
|
39
|
+
return text.trim().split("\n").slice(-8).join("\n");
|
|
40
|
+
}
|
|
@@ -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
|
+
}
|