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
package/dist/cli/info.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { frontmatterSchema } from "../content/markdown.js";
|
|
3
|
+
import { CONFIG_FILES, configSchema } from "../core/config.js";
|
|
4
|
+
/**
|
|
5
|
+
* Machine-readable description of the framework for coding agents. Commands
|
|
6
|
+
* and schemas are read from the live CLI and Zod definitions, so they cannot
|
|
7
|
+
* drift from the implementation.
|
|
8
|
+
*/
|
|
9
|
+
export function frameworkInfo(cli, version) {
|
|
10
|
+
const schema = (s) => z.toJSONSchema(s, { io: "input", unrepresentable: "any" });
|
|
11
|
+
return {
|
|
12
|
+
name: "vagejs",
|
|
13
|
+
version,
|
|
14
|
+
commands: cli.commands.map((c) => ({
|
|
15
|
+
usage: `vage ${c.rawName}`,
|
|
16
|
+
description: c.description,
|
|
17
|
+
options: c.options
|
|
18
|
+
.filter((o) => o.name !== "help")
|
|
19
|
+
.map((o) => ({ flags: o.rawName, description: o.description, default: o.config.default })),
|
|
20
|
+
})),
|
|
21
|
+
exitCodes: { 0: "success", 1: "failure (build error, check errors, or check warnings with --strict)" },
|
|
22
|
+
config: { files: CONFIG_FILES, schema: schema(configSchema) },
|
|
23
|
+
frontmatter: { schema: schema(frontmatterSchema) },
|
|
24
|
+
routing: {
|
|
25
|
+
pagesDir: "src/pages",
|
|
26
|
+
rules: [
|
|
27
|
+
"src/pages/index.md -> /",
|
|
28
|
+
"src/pages/about.md -> /about/",
|
|
29
|
+
"src/pages/blog/index.md -> /blog/",
|
|
30
|
+
"src/pages/blog/post.md -> /blog/post/",
|
|
31
|
+
"src/pages/404.md -> dist/404.html (not a route)",
|
|
32
|
+
"files or folders starting with _ are ignored",
|
|
33
|
+
"two files mapping to one route is an error",
|
|
34
|
+
],
|
|
35
|
+
layouts: "src/layouts/<name>.ts default-exports ({ content, page, site }) => string and must contain </head>",
|
|
36
|
+
},
|
|
37
|
+
generatedFiles: ["robots.txt", "sitemap.xml", "llms.txt", "llms-full.txt (opt-in)", "404.html", "redirect pages", "<indexnow-key>.txt (opt-in)", "_redirects (cloudflare-pages target)"],
|
|
38
|
+
environment: {
|
|
39
|
+
PAGESPEED_API_KEY: "your own key; required for `vage audit --url` (or save it with `vage connect pagespeed`)",
|
|
40
|
+
CRUX_API_KEY: "your own key; CrUX field data for `vage audit --url` (falls back to the PageSpeed key; or `vage connect crux`)",
|
|
41
|
+
GOOGLE_OAUTH_CLIENT_ID: "required for `vage connect`; Google Cloud OAuth client of type Desktop app",
|
|
42
|
+
GOOGLE_OAUTH_CLIENT_SECRET: "optional; the Desktop client's secret",
|
|
43
|
+
VAGE_CONFIG_DIR: "optional; where the Google token is stored (default: OS user config dir)",
|
|
44
|
+
CLOUDFLARE_API_TOKEN: "for `vage deploy --target cloudflare-pages` (or run `npx wrangler login`)",
|
|
45
|
+
CLOUDFLARE_ACCOUNT_ID: "for `vage deploy --target cloudflare-pages`",
|
|
46
|
+
},
|
|
47
|
+
notImplemented: [
|
|
48
|
+
"Bing Webmaster Tools API (use IndexNow for URL submission)",
|
|
49
|
+
"Netlify and Vercel deploy adapters",
|
|
50
|
+
"CMS / external content-source adapters",
|
|
51
|
+
"hreflang / multi-language sites, base paths, sitemap indexes",
|
|
52
|
+
"MCP server",
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const frontmatterSchema: z.ZodObject<{
|
|
3
|
+
title: z.ZodString;
|
|
4
|
+
description: z.ZodOptional<z.ZodString>;
|
|
5
|
+
draft: z.ZodDefault<z.ZodBoolean>;
|
|
6
|
+
layout: z.ZodOptional<z.ZodString>;
|
|
7
|
+
noindex: z.ZodDefault<z.ZodBoolean>;
|
|
8
|
+
canonical: z.ZodOptional<z.ZodString>;
|
|
9
|
+
image: z.ZodOptional<z.ZodString>;
|
|
10
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
11
|
+
article: "article";
|
|
12
|
+
website: "website";
|
|
13
|
+
}>>;
|
|
14
|
+
author: z.ZodOptional<z.ZodString>;
|
|
15
|
+
datePublished: z.ZodOptional<z.ZodPreprocess<z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>, unknown>>;
|
|
16
|
+
dateModified: z.ZodOptional<z.ZodPreprocess<z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>, unknown>>;
|
|
17
|
+
}, z.core.$loose>;
|
|
18
|
+
export type Frontmatter = z.output<typeof frontmatterSchema>;
|
|
19
|
+
export interface MarkdownPage {
|
|
20
|
+
frontmatter: Frontmatter;
|
|
21
|
+
/** Markdown body without frontmatter. */
|
|
22
|
+
body: string;
|
|
23
|
+
/** Rendered HTML of the Markdown body. */
|
|
24
|
+
html: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseMarkdown(source: string, file: string): MarkdownPage;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Marked } from "marked";
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { VageError } from "../core/errors.js";
|
|
5
|
+
// YAML may produce Date objects for unquoted timestamps; normalize to ISO strings.
|
|
6
|
+
const isoDate = z.preprocess((v) => (v instanceof Date ? v.toISOString() : v), z.union([z.iso.date(), z.iso.datetime({ offset: true })], { error: "must be an ISO date (2026-01-31) or datetime" }));
|
|
7
|
+
// Single line, no control characters: these values end up in <title>, meta tags and llms.txt lines.
|
|
8
|
+
const singleLine = z.string().regex(/^[^\u0000-\u001f\u007f]*$/, "must be a single line without control characters");
|
|
9
|
+
// A site path ("/x", not "//host") or an absolute http(s) URL.
|
|
10
|
+
const pathOrUrl = /^(\/(?!\/)|https?:\/\/)/;
|
|
11
|
+
export const frontmatterSchema = z.looseObject({
|
|
12
|
+
title: singleLine.min(1, "title is required").describe("Page title. Required; never invented."),
|
|
13
|
+
description: singleLine.min(1).optional().describe("Meta description, og:description and llms.txt summary."),
|
|
14
|
+
draft: z.boolean().default(false).describe("Skipped in builds; still visible in dev."),
|
|
15
|
+
layout: z
|
|
16
|
+
.string()
|
|
17
|
+
.regex(/^[\w-]+$/, "layout must be a file name without extension")
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Loads src/layouts/<layout>.ts. Defaults to 'default'."),
|
|
20
|
+
noindex: z.boolean().default(false).describe("Adds robots noindex; excludes the page from sitemap.xml and llms.txt."),
|
|
21
|
+
canonical: z
|
|
22
|
+
.string()
|
|
23
|
+
.regex(pathOrUrl, 'must be a site path starting with a single "/" or an http(s) URL')
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("Canonical URL override (site path or absolute URL). Defaults to the page's own URL."),
|
|
26
|
+
image: z.string().regex(pathOrUrl, 'must be a site path starting with a single "/" or an http(s) URL').optional().describe("Social preview image: site path (e.g. /images/og.png) or absolute URL."),
|
|
27
|
+
type: z.enum(["website", "article"]).default("website").describe('"article" emits og:type=article and Article JSON-LD.'),
|
|
28
|
+
author: singleLine.min(1).optional().describe("Article author name. Only use a real author."),
|
|
29
|
+
datePublished: isoDate.optional().describe("ISO date or datetime. Only use a real date."),
|
|
30
|
+
dateModified: isoDate.optional().describe("ISO date or datetime; also the sitemap <lastmod>."),
|
|
31
|
+
});
|
|
32
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
33
|
+
const marked = new Marked({ gfm: true });
|
|
34
|
+
export function parseMarkdown(source, file) {
|
|
35
|
+
const match = FRONTMATTER_RE.exec(source);
|
|
36
|
+
let data = {};
|
|
37
|
+
if (match) {
|
|
38
|
+
try {
|
|
39
|
+
data = parseYaml(match[1] ?? "") ?? {};
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw new VageError(`Invalid YAML frontmatter: ${err.message}`, file);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const result = frontmatterSchema.safeParse(data);
|
|
46
|
+
if (!result.success) {
|
|
47
|
+
throw new VageError(`Invalid frontmatter:\n${z.prettifyError(result.error)}`, file);
|
|
48
|
+
}
|
|
49
|
+
const body = match ? source.slice(match[0].length) : source;
|
|
50
|
+
return { frontmatter: result.data, body, html: marked.parse(body, { async: false }) };
|
|
51
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitizes rendered Markdown HTML. Page content may be written by AI agents,
|
|
3
|
+
* contributors or (later) CMS sources, so scripts, event handlers, javascript:
|
|
4
|
+
* URLs, iframes, forms and <meta>/<base>/<style> tricks are removed. data-*
|
|
5
|
+
* attributes (used by analytics events) are kept. Loaded lazily: jsdom is heavy.
|
|
6
|
+
*/
|
|
7
|
+
export declare function sanitizeHtml(html: string): Promise<string>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
let purifier;
|
|
2
|
+
/**
|
|
3
|
+
* Sanitizes rendered Markdown HTML. Page content may be written by AI agents,
|
|
4
|
+
* contributors or (later) CMS sources, so scripts, event handlers, javascript:
|
|
5
|
+
* URLs, iframes, forms and <meta>/<base>/<style> tricks are removed. data-*
|
|
6
|
+
* attributes (used by analytics events) are kept. Loaded lazily: jsdom is heavy.
|
|
7
|
+
*/
|
|
8
|
+
export async function sanitizeHtml(html) {
|
|
9
|
+
if (!purifier) {
|
|
10
|
+
const [{ JSDOM }, { default: createDOMPurify }] = await Promise.all([import("jsdom"), import("dompurify")]);
|
|
11
|
+
purifier = createDOMPurify(new JSDOM("").window);
|
|
12
|
+
}
|
|
13
|
+
return purifier.sanitize(html, {
|
|
14
|
+
FORBID_TAGS: ["style", "form", "input", "button", "textarea", "select", "option", "base", "link", "meta", "object", "embed"],
|
|
15
|
+
ALLOW_DATA_ATTR: true,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type SiteModel } from "./site.js";
|
|
2
|
+
export declare const OUT_DIR = "dist";
|
|
3
|
+
export interface BuildResult {
|
|
4
|
+
outDir: string;
|
|
5
|
+
pages: {
|
|
6
|
+
route: string;
|
|
7
|
+
source: string;
|
|
8
|
+
output: string;
|
|
9
|
+
}[];
|
|
10
|
+
redirects: {
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
output: string;
|
|
14
|
+
}[];
|
|
15
|
+
files: string[];
|
|
16
|
+
skippedDrafts: string[];
|
|
17
|
+
warnings: string[];
|
|
18
|
+
durationMs: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function build(root: string): Promise<BuildResult>;
|
|
21
|
+
/** Builds and also returns the rendered site model (for audits). */
|
|
22
|
+
export declare function buildSite(root: string): Promise<{
|
|
23
|
+
result: BuildResult;
|
|
24
|
+
model: SiteModel;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { VageError } from "./errors.js";
|
|
4
|
+
import { createBuildLoader } from "./loader.js";
|
|
5
|
+
import { renderSite } from "./site.js";
|
|
6
|
+
export const OUT_DIR = "dist";
|
|
7
|
+
export async function build(root) {
|
|
8
|
+
return (await buildSite(root)).result;
|
|
9
|
+
}
|
|
10
|
+
/** Builds and also returns the rendered site model (for audits). */
|
|
11
|
+
export async function buildSite(root) {
|
|
12
|
+
const start = performance.now();
|
|
13
|
+
root = path.resolve(root);
|
|
14
|
+
const outDir = path.join(root, OUT_DIR);
|
|
15
|
+
const loader = await createBuildLoader(root);
|
|
16
|
+
try {
|
|
17
|
+
// Render everything before touching dist/ so a failed build leaves the previous output intact.
|
|
18
|
+
const model = await renderSite(root, loader.load);
|
|
19
|
+
const outputs = new Map();
|
|
20
|
+
for (const page of model.pages)
|
|
21
|
+
outputs.set(page.outFile, page.html);
|
|
22
|
+
if (model.notFound)
|
|
23
|
+
outputs.set(model.notFound.outFile, model.notFound.html);
|
|
24
|
+
for (const r of model.redirects)
|
|
25
|
+
outputs.set(r.outFile, r.html);
|
|
26
|
+
for (const [name, content] of model.files)
|
|
27
|
+
outputs.set(name, content);
|
|
28
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
29
|
+
const publicDir = path.join(root, "public");
|
|
30
|
+
if (fs.existsSync(publicDir))
|
|
31
|
+
fs.cpSync(publicDir, outDir, { recursive: true });
|
|
32
|
+
for (const [output, content] of outputs) {
|
|
33
|
+
const target = path.resolve(outDir, output);
|
|
34
|
+
// Defense in depth: config is validated, but never write outside dist/.
|
|
35
|
+
if (!target.startsWith(outDir + path.sep))
|
|
36
|
+
throw new VageError(`Refusing to write outside ${OUT_DIR}/: ${output}`);
|
|
37
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
38
|
+
fs.writeFileSync(target, content);
|
|
39
|
+
}
|
|
40
|
+
const result = {
|
|
41
|
+
outDir,
|
|
42
|
+
pages: model.pages.map((p) => ({ route: p.route, source: p.source, output: p.outFile })),
|
|
43
|
+
redirects: model.redirects.map((r) => ({ from: r.from, to: r.to, output: r.outFile })),
|
|
44
|
+
files: [...model.files.keys()],
|
|
45
|
+
skippedDrafts: model.skippedDrafts,
|
|
46
|
+
warnings: model.warnings,
|
|
47
|
+
durationMs: Math.round(performance.now() - start),
|
|
48
|
+
};
|
|
49
|
+
return { result, model };
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await loader.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const CONFIG_FILES: string[];
|
|
3
|
+
export declare const configSchema: z.ZodObject<{
|
|
4
|
+
site: z.ZodOptional<z.ZodPipe<z.ZodURL, z.ZodTransform<string, string>>>;
|
|
5
|
+
title: z.ZodOptional<z.ZodString>;
|
|
6
|
+
description: z.ZodOptional<z.ZodString>;
|
|
7
|
+
lang: z.ZodDefault<z.ZodString>;
|
|
8
|
+
markdown: z.ZodDefault<z.ZodObject<{
|
|
9
|
+
html: z.ZodDefault<z.ZodEnum<{
|
|
10
|
+
allow: "allow";
|
|
11
|
+
sanitize: "sanitize";
|
|
12
|
+
}>>;
|
|
13
|
+
}, z.core.$strict>>;
|
|
14
|
+
seo: z.ZodDefault<z.ZodObject<{
|
|
15
|
+
robots: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
16
|
+
rules: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
17
|
+
userAgent: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
|
|
18
|
+
allow: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
19
|
+
disallow: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
20
|
+
}, z.core.$strict>>>;
|
|
21
|
+
aiTraining: z.ZodOptional<z.ZodEnum<{
|
|
22
|
+
allow: "allow";
|
|
23
|
+
disallow: "disallow";
|
|
24
|
+
}>>;
|
|
25
|
+
aiSearch: z.ZodOptional<z.ZodEnum<{
|
|
26
|
+
allow: "allow";
|
|
27
|
+
disallow: "disallow";
|
|
28
|
+
}>>;
|
|
29
|
+
}, z.core.$strict>]>>, z.ZodTransform<false | {
|
|
30
|
+
rules: {
|
|
31
|
+
userAgent: string | string[];
|
|
32
|
+
allow: string[];
|
|
33
|
+
disallow: string[];
|
|
34
|
+
}[];
|
|
35
|
+
aiTraining?: "allow" | "disallow" | undefined;
|
|
36
|
+
aiSearch?: "allow" | "disallow" | undefined;
|
|
37
|
+
}, boolean | {
|
|
38
|
+
rules: {
|
|
39
|
+
userAgent: string | string[];
|
|
40
|
+
allow: string[];
|
|
41
|
+
disallow: string[];
|
|
42
|
+
}[];
|
|
43
|
+
aiTraining?: "allow" | "disallow" | undefined;
|
|
44
|
+
aiSearch?: "allow" | "disallow" | undefined;
|
|
45
|
+
}>>;
|
|
46
|
+
sitemap: z.ZodDefault<z.ZodBoolean>;
|
|
47
|
+
llmsTxt: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
48
|
+
full: z.ZodDefault<z.ZodBoolean>;
|
|
49
|
+
}, z.core.$strict>]>>, z.ZodTransform<false | {
|
|
50
|
+
full: boolean;
|
|
51
|
+
}, boolean | {
|
|
52
|
+
full: boolean;
|
|
53
|
+
}>>;
|
|
54
|
+
structuredData: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
55
|
+
organization: z.ZodOptional<z.ZodObject<{
|
|
56
|
+
name: z.ZodString;
|
|
57
|
+
url: z.ZodOptional<z.ZodURL>;
|
|
58
|
+
logo: z.ZodOptional<z.ZodString>;
|
|
59
|
+
sameAs: z.ZodOptional<z.ZodArray<z.ZodURL>>;
|
|
60
|
+
}, z.core.$strict>>;
|
|
61
|
+
}, z.core.$strict>]>>, z.ZodTransform<false | {
|
|
62
|
+
organization?: {
|
|
63
|
+
name: string;
|
|
64
|
+
url?: string | undefined;
|
|
65
|
+
logo?: string | undefined;
|
|
66
|
+
sameAs?: string[] | undefined;
|
|
67
|
+
} | undefined;
|
|
68
|
+
}, boolean | {
|
|
69
|
+
organization?: {
|
|
70
|
+
name: string;
|
|
71
|
+
url?: string | undefined;
|
|
72
|
+
logo?: string | undefined;
|
|
73
|
+
sameAs?: string[] | undefined;
|
|
74
|
+
} | undefined;
|
|
75
|
+
}>>;
|
|
76
|
+
}, z.core.$strict>>;
|
|
77
|
+
performance: z.ZodDefault<z.ZodObject<{
|
|
78
|
+
budgets: z.ZodDefault<z.ZodObject<{
|
|
79
|
+
performance: z.ZodDefault<z.ZodNumber>;
|
|
80
|
+
accessibility: z.ZodDefault<z.ZodNumber>;
|
|
81
|
+
seo: z.ZodDefault<z.ZodNumber>;
|
|
82
|
+
bestPractices: z.ZodDefault<z.ZodNumber>;
|
|
83
|
+
javascriptKb: z.ZodDefault<z.ZodNumber>;
|
|
84
|
+
cssKb: z.ZodDefault<z.ZodNumber>;
|
|
85
|
+
imageKb: z.ZodDefault<z.ZodNumber>;
|
|
86
|
+
fontKb: z.ZodDefault<z.ZodNumber>;
|
|
87
|
+
totalKb: z.ZodDefault<z.ZodNumber>;
|
|
88
|
+
requests: z.ZodDefault<z.ZodNumber>;
|
|
89
|
+
}, z.core.$strict>>;
|
|
90
|
+
lighthouse: z.ZodDefault<z.ZodObject<{
|
|
91
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
92
|
+
pages: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
93
|
+
}, z.core.$strict>>;
|
|
94
|
+
}, z.core.$strict>>;
|
|
95
|
+
deploy: z.ZodDefault<z.ZodObject<{
|
|
96
|
+
target: z.ZodDefault<z.ZodEnum<{
|
|
97
|
+
"cloudflare-pages": "cloudflare-pages";
|
|
98
|
+
"github-pages": "github-pages";
|
|
99
|
+
}>>;
|
|
100
|
+
cloudflareProject: z.ZodOptional<z.ZodString>;
|
|
101
|
+
}, z.core.$strict>>;
|
|
102
|
+
security: z.ZodDefault<z.ZodObject<{
|
|
103
|
+
headers: z.ZodDefault<z.ZodBoolean>;
|
|
104
|
+
csp: z.ZodDefault<z.ZodEnum<{
|
|
105
|
+
enforce: "enforce";
|
|
106
|
+
off: "off";
|
|
107
|
+
"report-only": "report-only";
|
|
108
|
+
}>>;
|
|
109
|
+
cspSources: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
|
110
|
+
"connect-src": "connect-src";
|
|
111
|
+
"font-src": "font-src";
|
|
112
|
+
"frame-src": "frame-src";
|
|
113
|
+
"img-src": "img-src";
|
|
114
|
+
"media-src": "media-src";
|
|
115
|
+
"script-src": "script-src";
|
|
116
|
+
"style-src": "style-src";
|
|
117
|
+
}> & z.core.$partial, z.ZodArray<z.ZodString>>>;
|
|
118
|
+
}, z.core.$strict>>;
|
|
119
|
+
guidelines: z.ZodDefault<z.ZodObject<{
|
|
120
|
+
rulePack: z.ZodOptional<z.ZodString>;
|
|
121
|
+
}, z.core.$strict>>;
|
|
122
|
+
integrations: z.ZodDefault<z.ZodObject<{
|
|
123
|
+
ga4: z.ZodOptional<z.ZodObject<{
|
|
124
|
+
measurementId: z.ZodOptional<z.ZodString>;
|
|
125
|
+
propertyId: z.ZodOptional<z.ZodString>;
|
|
126
|
+
consentDefault: z.ZodDefault<z.ZodEnum<{
|
|
127
|
+
denied: "denied";
|
|
128
|
+
granted: "granted";
|
|
129
|
+
}>>;
|
|
130
|
+
events: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
131
|
+
params: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
132
|
+
}, z.core.$strict>>>;
|
|
133
|
+
}, z.core.$strict>>;
|
|
134
|
+
gtm: z.ZodOptional<z.ZodObject<{
|
|
135
|
+
containerId: z.ZodOptional<z.ZodString>;
|
|
136
|
+
consentDefault: z.ZodDefault<z.ZodEnum<{
|
|
137
|
+
denied: "denied";
|
|
138
|
+
granted: "granted";
|
|
139
|
+
}>>;
|
|
140
|
+
events: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
141
|
+
params: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
142
|
+
}, z.core.$strict>>>;
|
|
143
|
+
}, z.core.$strict>>;
|
|
144
|
+
plausible: z.ZodOptional<z.ZodObject<{
|
|
145
|
+
snippet: z.ZodString;
|
|
146
|
+
events: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
147
|
+
params: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
148
|
+
}, z.core.$strict>>>;
|
|
149
|
+
}, z.core.$strict>>;
|
|
150
|
+
clarity: z.ZodOptional<z.ZodObject<{
|
|
151
|
+
snippet: z.ZodString;
|
|
152
|
+
}, z.core.$strict>>;
|
|
153
|
+
indexNow: z.ZodOptional<z.ZodObject<{
|
|
154
|
+
key: z.ZodString;
|
|
155
|
+
endpoint: z.ZodDefault<z.ZodURL>;
|
|
156
|
+
}, z.core.$strict>>;
|
|
157
|
+
searchConsole: z.ZodOptional<z.ZodObject<{
|
|
158
|
+
property: z.ZodOptional<z.ZodString>;
|
|
159
|
+
}, z.core.$strict>>;
|
|
160
|
+
}, z.core.$strict>>;
|
|
161
|
+
redirects: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
162
|
+
}, z.core.$strict>;
|
|
163
|
+
export type UserConfig = z.input<typeof configSchema>;
|
|
164
|
+
export type SiteConfig = z.output<typeof configSchema>;
|
|
165
|
+
/** Identity helper that gives config files type checking. */
|
|
166
|
+
export declare function defineConfig(config: UserConfig): UserConfig;
|
|
167
|
+
export declare function findConfigFile(root: string): string | undefined;
|
|
168
|
+
export type ModuleLoader = (file: string) => Promise<Record<string, unknown>>;
|
|
169
|
+
export declare function loadConfig(root: string, load: ModuleLoader): Promise<SiteConfig>;
|
|
170
|
+
/** Validates a raw config object and applies defaults. */
|
|
171
|
+
export declare function parseConfig(raw: unknown, file?: string): SiteConfig;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { VageError } from "./errors.js";
|
|
5
|
+
import { ruleData } from "../guidelines/pack.js";
|
|
6
|
+
export const CONFIG_FILES = ["vage.config.ts", "vage.config.js", "vage.config.mjs"];
|
|
7
|
+
/** `true` -> defaults, `false` -> disabled, object -> options. */
|
|
8
|
+
function toggle(options) {
|
|
9
|
+
return z
|
|
10
|
+
.union([z.boolean(), options])
|
|
11
|
+
.default(true)
|
|
12
|
+
.transform((v) => (v === true ? options.parse({}) : v === false ? false : v));
|
|
13
|
+
}
|
|
14
|
+
// No whitespace or control characters: these values become robots.txt lines and output paths.
|
|
15
|
+
const token = z.string().min(1).regex(/^[^\s\u0000-\u001f\u007f]+$/, "must not contain spaces or line breaks");
|
|
16
|
+
const userAgents = z.union([token, z.array(token).min(1)]);
|
|
17
|
+
const urlPath = token.regex(/^\//, 'must start with "/"');
|
|
18
|
+
/** Redirect source: a site path without ".." segments (it becomes a file path under dist/). */
|
|
19
|
+
const redirectSource = urlPath.refine((p) => !p.split("/").some((seg) => seg === ".." || seg === "."), 'must not contain "." or ".." segments');
|
|
20
|
+
/** Redirect target: a site path ("/x", not "//host") or an absolute http(s) URL. */
|
|
21
|
+
const redirectTarget = token.regex(/^(\/(?!\/)|https?:\/\/)/, 'must be a site path starting with a single "/" or an http(s) URL');
|
|
22
|
+
const robotsSchema = z.strictObject({
|
|
23
|
+
rules: z
|
|
24
|
+
.array(z.strictObject({
|
|
25
|
+
userAgent: userAgents,
|
|
26
|
+
allow: z.array(urlPath).default([]),
|
|
27
|
+
disallow: z.array(urlPath).default([]),
|
|
28
|
+
}))
|
|
29
|
+
.default([])
|
|
30
|
+
.describe("Explicit robots.txt groups, written in order."),
|
|
31
|
+
aiTraining: z
|
|
32
|
+
.enum(["allow", "disallow"])
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Groups for documented AI training crawlers (GPTBot, ClaudeBot, Google-Extended). Unset: the * group applies."),
|
|
35
|
+
aiSearch: z
|
|
36
|
+
.enum(["allow", "disallow"])
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Groups for documented AI search crawlers (OAI-SearchBot, Claude-SearchBot). Unset: the * group applies."),
|
|
39
|
+
});
|
|
40
|
+
const seoSchema = z.strictObject({
|
|
41
|
+
robots: toggle(robotsSchema).describe("Generate robots.txt (true/false or options)."),
|
|
42
|
+
sitemap: z.boolean().default(true).describe("Generate sitemap.xml (needs `site`)."),
|
|
43
|
+
llmsTxt: toggle(z.strictObject({
|
|
44
|
+
full: z.boolean().default(false).describe("Also emit llms-full.txt with the Markdown of every listed page."),
|
|
45
|
+
})).describe("Generate llms.txt (needs `title`)."),
|
|
46
|
+
structuredData: toggle(z.strictObject({
|
|
47
|
+
organization: z
|
|
48
|
+
.strictObject({
|
|
49
|
+
name: z.string().min(1),
|
|
50
|
+
url: z.url().optional(),
|
|
51
|
+
logo: z.string().min(1).optional(),
|
|
52
|
+
sameAs: z.array(z.url()).optional(),
|
|
53
|
+
})
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Organization JSON-LD on the home page. Only true, visible facts."),
|
|
56
|
+
})).describe("Automatic JSON-LD (WebSite, Organization, Article)."),
|
|
57
|
+
});
|
|
58
|
+
const score = z.number().int().min(0).max(100);
|
|
59
|
+
const kb = z.number().positive();
|
|
60
|
+
const budgetsSchema = z.strictObject({
|
|
61
|
+
performance: score.default(90).describe("Minimum Lighthouse performance score (lab)."),
|
|
62
|
+
accessibility: score.default(95).describe("Minimum Lighthouse accessibility score (lab)."),
|
|
63
|
+
seo: score.default(95).describe("Minimum Lighthouse SEO score (lab)."),
|
|
64
|
+
bestPractices: score.default(90).describe("Minimum Lighthouse best-practices score (lab)."),
|
|
65
|
+
javascriptKb: kb.default(100).describe("Max uncompressed JavaScript per page, KB."),
|
|
66
|
+
cssKb: kb.default(50).describe("Max uncompressed CSS per page, KB."),
|
|
67
|
+
imageKb: kb.default(500).describe("Max image bytes per page, KB."),
|
|
68
|
+
fontKb: kb.default(150).describe("Max font bytes per page, KB."),
|
|
69
|
+
totalKb: kb.default(1000).describe("Max total local bytes per page, KB."),
|
|
70
|
+
requests: z.number().int().positive().default(30).describe("Max resource requests per page (incl. the HTML)."),
|
|
71
|
+
});
|
|
72
|
+
const performanceSchema = z.strictObject({
|
|
73
|
+
budgets: budgetsSchema.default(() => budgetsSchema.parse({})).describe("Starting points, not universal standards. Adjust per site."),
|
|
74
|
+
lighthouse: z
|
|
75
|
+
.strictObject({
|
|
76
|
+
enabled: z.boolean().default(false).describe("Run Lighthouse in `vage audit` without --lighthouse. Needs the lighthouse package and Chrome."),
|
|
77
|
+
pages: z.array(z.string().regex(/^\//)).min(1).default(["/"]).describe("Routes to audit with Lighthouse."),
|
|
78
|
+
})
|
|
79
|
+
.default({ enabled: false, pages: ["/"] }),
|
|
80
|
+
});
|
|
81
|
+
// GA4 naming rules and limits: rule pack "ga4.naming".
|
|
82
|
+
const GA4 = ruleData("ga4.naming");
|
|
83
|
+
const ga4Name = z
|
|
84
|
+
.string()
|
|
85
|
+
.max(GA4.maxNameLength)
|
|
86
|
+
.regex(/^[A-Za-z][A-Za-z0-9_]*$/, "must start with a letter and use only letters, numbers and underscores");
|
|
87
|
+
const ga4Param = ga4Name.refine((p) => !GA4.reservedParamPrefixes.some((prefix) => p.startsWith(prefix)), "uses a reserved prefix");
|
|
88
|
+
/** Event map whose keys are validated with GA4 naming rules (record keys alone give unhelpful errors). */
|
|
89
|
+
const ga4Events = z.record(z.string(), z.strictObject({ params: z.array(ga4Param).default([]) })).superRefine((events, ctx) => {
|
|
90
|
+
for (const name of Object.keys(events)) {
|
|
91
|
+
const result = ga4Name.safeParse(name);
|
|
92
|
+
if (!result.success)
|
|
93
|
+
ctx.addIssue({ code: "custom", path: [name], message: `event name "${name}" ${result.error.issues[0].message}` });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
const consentDefault = z
|
|
97
|
+
.enum(["denied", "granted"])
|
|
98
|
+
.default("denied")
|
|
99
|
+
.describe("Consent mode default for analytics/ads storage before the visitor chooses. Your site provides the consent UI.");
|
|
100
|
+
/** A tracking snippet pasted from the provider's dashboard; inserted verbatim into <head>. */
|
|
101
|
+
const snippet = z
|
|
102
|
+
.string()
|
|
103
|
+
.max(5000)
|
|
104
|
+
.refine((v) => /<script[\s>]/i.test(v), "must be the <script> snippet from the provider's dashboard")
|
|
105
|
+
.refine((v) => !/googletagmanager\.com/.test(v), "Google tags belong in integrations.ga4 or integrations.gtm");
|
|
106
|
+
const INDEXNOW = ruleData("indexnow.protocol");
|
|
107
|
+
const integrationsSchema = z
|
|
108
|
+
.strictObject({
|
|
109
|
+
ga4: z
|
|
110
|
+
.strictObject({
|
|
111
|
+
measurementId: z
|
|
112
|
+
.string()
|
|
113
|
+
.regex(/^G-[A-Z0-9]+$/, 'must look like "G-XXXXXXX"')
|
|
114
|
+
.optional()
|
|
115
|
+
.describe("GA4 web stream measurement ID (public). Tags are added only when set, e.g. process.env.GA4_MEASUREMENT_ID."),
|
|
116
|
+
propertyId: z.string().regex(/^\d+$/, "must be the numeric GA4 property ID").optional().describe("Numeric property ID for `vage report analytics`."),
|
|
117
|
+
consentDefault,
|
|
118
|
+
events: ga4Events
|
|
119
|
+
.default({})
|
|
120
|
+
.describe('Allowed custom events for data-vage-event attributes, e.g. { signup_click: { params: ["plan"] } }.'),
|
|
121
|
+
})
|
|
122
|
+
.optional(),
|
|
123
|
+
gtm: z
|
|
124
|
+
.strictObject({
|
|
125
|
+
containerId: z.string().regex(/^GTM-[A-Z0-9]+$/, 'must look like "GTM-XXXXXX"').optional().describe("Google Tag Manager container ID."),
|
|
126
|
+
consentDefault,
|
|
127
|
+
events: ga4Events.default({}),
|
|
128
|
+
})
|
|
129
|
+
.optional(),
|
|
130
|
+
plausible: z
|
|
131
|
+
.strictObject({
|
|
132
|
+
snippet: snippet.describe("Your site's script snippet from Plausible (Site settings → Site installation)."),
|
|
133
|
+
events: z
|
|
134
|
+
.record(z.string().min(1).max(120), z.strictObject({ params: z.array(z.string().min(1)).default([]) }))
|
|
135
|
+
.default({})
|
|
136
|
+
.describe("Custom events for data-vage-event, sent with plausible(name, { props })."),
|
|
137
|
+
})
|
|
138
|
+
.optional(),
|
|
139
|
+
clarity: z
|
|
140
|
+
.strictObject({
|
|
141
|
+
snippet: snippet.describe("Tracking code from Clarity (Settings → Setup → Install manually)."),
|
|
142
|
+
})
|
|
143
|
+
.optional()
|
|
144
|
+
.describe("Microsoft Clarity. window.vageConsent.update() also sends Clarity's consentv2 signal."),
|
|
145
|
+
indexNow: z
|
|
146
|
+
.strictObject({
|
|
147
|
+
key: z.string().regex(new RegExp(INDEXNOW.keyPattern), "must be 8-128 characters: letters, digits or dashes").describe("Public IndexNow key; served as /<key>.txt."),
|
|
148
|
+
endpoint: z.url().default(INDEXNOW.endpoint).describe("IndexNow endpoint (default: the shared api.indexnow.org)."),
|
|
149
|
+
})
|
|
150
|
+
.optional(),
|
|
151
|
+
searchConsole: z
|
|
152
|
+
.strictObject({
|
|
153
|
+
property: z
|
|
154
|
+
.string()
|
|
155
|
+
.regex(/^(sc-domain:[^/\s]+|https?:\/\/\S+\/)$/, 'must be "sc-domain:example.com" or a URL prefix ending in "/"')
|
|
156
|
+
.optional()
|
|
157
|
+
.describe("Search Console property for `vage report search`."),
|
|
158
|
+
})
|
|
159
|
+
.optional(),
|
|
160
|
+
})
|
|
161
|
+
.refine((i) => !(i.ga4?.measurementId && i.gtm?.containerId), {
|
|
162
|
+
message: "Configure GA4 directly (ga4.measurementId) or through Tag Manager (gtm.containerId), not both: loading both double-counts.",
|
|
163
|
+
});
|
|
164
|
+
export const configSchema = z.strictObject({
|
|
165
|
+
site: z
|
|
166
|
+
.url({ protocol: /^https?$/ })
|
|
167
|
+
.refine((s) => new URL(s).pathname === "/", "must be an origin without a path (base paths are not supported yet)")
|
|
168
|
+
.transform((s) => new URL(s).origin)
|
|
169
|
+
.optional()
|
|
170
|
+
.describe("Production origin, e.g. https://example.com. Needed for canonical URLs and sitemap.xml."),
|
|
171
|
+
title: z.string().min(1).optional().describe("Site name: page titles, og:site_name, llms.txt heading."),
|
|
172
|
+
description: z.string().min(1).optional().describe("Fallback meta description; llms.txt summary."),
|
|
173
|
+
lang: z.string().min(1).default("en").describe("<html lang> value."),
|
|
174
|
+
markdown: z
|
|
175
|
+
.strictObject({
|
|
176
|
+
html: z
|
|
177
|
+
.enum(["sanitize", "allow"])
|
|
178
|
+
.default("sanitize")
|
|
179
|
+
.describe('"sanitize" removes scripts, event handlers, javascript: URLs, iframes and forms from page content. "allow" trusts all content authors.'),
|
|
180
|
+
})
|
|
181
|
+
.default({ html: "sanitize" }),
|
|
182
|
+
seo: seoSchema.default(() => seoSchema.parse({})),
|
|
183
|
+
performance: performanceSchema.default(() => performanceSchema.parse({})),
|
|
184
|
+
deploy: z
|
|
185
|
+
.strictObject({
|
|
186
|
+
target: z.enum(["github-pages", "cloudflare-pages"]).default("github-pages").describe("Host used by `vage deploy`."),
|
|
187
|
+
cloudflareProject: z.string().regex(/^[a-z0-9][a-z0-9-]{0,57}$/).optional().describe("Cloudflare Pages project name."),
|
|
188
|
+
})
|
|
189
|
+
.default({ target: "github-pages" }),
|
|
190
|
+
security: z
|
|
191
|
+
.strictObject({
|
|
192
|
+
headers: z.boolean().default(true).describe("Write a _headers file with security headers (Cloudflare Pages target)."),
|
|
193
|
+
csp: z
|
|
194
|
+
.enum(["enforce", "report-only", "off"])
|
|
195
|
+
.default("enforce")
|
|
196
|
+
.describe("Content Security Policy generated from the built pages: hashes of inline scripts/styles plus the origins your layouts and analytics load."),
|
|
197
|
+
cspSources: z
|
|
198
|
+
.partialRecord(z.enum(["script-src", "style-src", "img-src", "font-src", "connect-src", "frame-src", "media-src"]), z.array(z.string().regex(/^('[a-z0-9-]+'|https?:\/\/[^\s;,']+|[a-z]+:)$/, "must be a source like https://cdn.example.com or 'self'")))
|
|
199
|
+
.default({})
|
|
200
|
+
.describe("Extra sources per directive, e.g. { \"connect-src\": [\"https://api.example.com\"] }."),
|
|
201
|
+
})
|
|
202
|
+
.default({ headers: true, csp: "enforce", cspSources: {} }),
|
|
203
|
+
guidelines: z
|
|
204
|
+
.strictObject({
|
|
205
|
+
rulePack: z
|
|
206
|
+
.string()
|
|
207
|
+
.regex(/^\d{4}\.\d{1,2}\.\d+$/)
|
|
208
|
+
.optional()
|
|
209
|
+
.describe("Pin the rule-pack version. Builds fail when the installed vagejs ships a different pack, until you review its changelog and update the pin."),
|
|
210
|
+
})
|
|
211
|
+
.default({}),
|
|
212
|
+
integrations: integrationsSchema.default({}).describe("Optional analytics and search integrations. All off unless configured."),
|
|
213
|
+
redirects: z
|
|
214
|
+
.record(z.string(), redirectTarget)
|
|
215
|
+
.superRefine((redirects, ctx) => {
|
|
216
|
+
for (const from of Object.keys(redirects)) {
|
|
217
|
+
const result = redirectSource.safeParse(from);
|
|
218
|
+
if (!result.success)
|
|
219
|
+
ctx.addIssue({ code: "custom", path: [from], message: `redirect source "${from}" ${result.error.issues[0].message}` });
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
.default({})
|
|
223
|
+
.describe("Old path -> new path or URL. Built as noindex meta-refresh pages; dev answers 301."),
|
|
224
|
+
});
|
|
225
|
+
/** Identity helper that gives config files type checking. */
|
|
226
|
+
export function defineConfig(config) {
|
|
227
|
+
return config;
|
|
228
|
+
}
|
|
229
|
+
export function findConfigFile(root) {
|
|
230
|
+
return CONFIG_FILES.map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
|
|
231
|
+
}
|
|
232
|
+
export async function loadConfig(root, load) {
|
|
233
|
+
const file = findConfigFile(root);
|
|
234
|
+
return parseConfig(file ? (await load(file)).default : {}, file);
|
|
235
|
+
}
|
|
236
|
+
/** Validates a raw config object and applies defaults. */
|
|
237
|
+
export function parseConfig(raw, file) {
|
|
238
|
+
const result = configSchema.safeParse(raw ?? {});
|
|
239
|
+
if (!result.success) {
|
|
240
|
+
throw new VageError(`Invalid config:\n${z.prettifyError(result.error)}`, file);
|
|
241
|
+
}
|
|
242
|
+
return result.data;
|
|
243
|
+
}
|