vagejs 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +244 -2
- package/dist/audit/a11y.d.ts +13 -0
- package/dist/audit/a11y.js +33 -0
- package/dist/audit/audit.d.ts +31 -0
- package/dist/audit/audit.js +143 -0
- package/dist/audit/budgets.d.ts +22 -0
- package/dist/audit/budgets.js +106 -0
- package/dist/audit/lighthouse.d.ts +3 -0
- package/dist/audit/lighthouse.js +48 -0
- package/dist/audit/remote.d.ts +65 -0
- package/dist/audit/remote.js +123 -0
- package/dist/audit/vitals.d.ts +28 -0
- package/dist/audit/vitals.js +18 -0
- package/dist/checks/check.d.ts +31 -0
- package/dist/checks/check.js +434 -0
- package/dist/checks/integrity.d.ts +36 -0
- package/dist/checks/integrity.js +145 -0
- package/dist/checks/links.d.ts +18 -0
- package/dist/checks/links.js +47 -0
- package/dist/checks/verify.d.ts +35 -0
- package/dist/checks/verify.js +167 -0
- package/dist/cli/create.d.ts +10 -0
- package/dist/cli/create.js +39 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +710 -0
- package/dist/cli/info.d.ts +82 -0
- package/dist/cli/info.js +100 -0
- package/dist/content/images.d.ts +23 -0
- package/dist/content/images.js +96 -0
- package/dist/content/markdown.d.ts +28 -0
- package/dist/content/markdown.js +56 -0
- package/dist/content/sanitize.d.ts +16 -0
- package/dist/content/sanitize.js +57 -0
- package/dist/core/build.d.ts +25 -0
- package/dist/core/build.js +54 -0
- package/dist/core/clone.d.ts +6 -0
- package/dist/core/clone.js +16 -0
- package/dist/core/config.d.ts +180 -0
- package/dist/core/config.js +260 -0
- package/dist/core/errors.d.ts +7 -0
- package/dist/core/errors.js +12 -0
- package/dist/core/html.d.ts +24 -0
- package/dist/core/html.js +42 -0
- package/dist/core/loader.d.ts +10 -0
- package/dist/core/loader.js +16 -0
- package/dist/core/pages.d.ts +40 -0
- package/dist/core/pages.js +93 -0
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +14 -0
- package/dist/core/render.d.ts +53 -0
- package/dist/core/render.js +104 -0
- package/dist/core/routes.d.ts +24 -0
- package/dist/core/routes.js +70 -0
- package/dist/core/site.d.ts +42 -0
- package/dist/core/site.js +182 -0
- package/dist/core/validate.d.ts +12 -0
- package/dist/core/validate.js +36 -0
- package/dist/deploy/cloudflare-pages.d.ts +20 -0
- package/dist/deploy/cloudflare-pages.js +42 -0
- package/dist/deploy/cloudflare-workers.d.ts +59 -0
- package/dist/deploy/cloudflare-workers.js +223 -0
- package/dist/deploy/git.d.ts +20 -0
- package/dist/deploy/git.js +55 -0
- package/dist/deploy/github-pages.d.ts +33 -0
- package/dist/deploy/github-pages.js +99 -0
- package/dist/deploy/headers.d.ts +11 -0
- package/dist/deploy/headers.js +103 -0
- package/dist/deploy/publish.d.ts +45 -0
- package/dist/deploy/publish.js +148 -0
- package/dist/deploy/redirects.d.ts +7 -0
- package/dist/deploy/redirects.js +18 -0
- package/dist/deploy/secrets.d.ts +6 -0
- package/dist/deploy/secrets.js +66 -0
- package/dist/dev/guard.d.ts +9 -0
- package/dist/dev/guard.js +57 -0
- package/dist/dev/listen.d.ts +6 -0
- package/dist/dev/listen.js +13 -0
- package/dist/dev/preview.d.ts +6 -0
- package/dist/dev/preview.js +72 -0
- package/dist/dev/server.d.ts +8 -0
- package/dist/dev/server.js +104 -0
- package/dist/discovery/crawlers.d.ts +13 -0
- package/dist/discovery/crawlers.js +2 -0
- package/dist/discovery/llms.d.ts +12 -0
- package/dist/discovery/llms.js +19 -0
- package/dist/discovery/robots.d.ts +8 -0
- package/dist/discovery/robots.js +40 -0
- package/dist/discovery/sitemap.d.ts +8 -0
- package/dist/discovery/sitemap.js +11 -0
- package/dist/guidelines/monitor.d.ts +55 -0
- package/dist/guidelines/monitor.js +126 -0
- package/dist/guidelines/pack.d.ts +41 -0
- package/dist/guidelines/pack.js +13 -0
- package/dist/guidelines/rule-pack.json +611 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/integrations/api-keys.d.ts +26 -0
- package/dist/integrations/api-keys.js +65 -0
- package/dist/integrations/ga4-data.d.ts +13 -0
- package/dist/integrations/ga4-data.js +39 -0
- package/dist/integrations/google-auth.d.ts +47 -0
- package/dist/integrations/google-auth.js +157 -0
- package/dist/integrations/http.d.ts +15 -0
- package/dist/integrations/http.js +40 -0
- package/dist/integrations/indexnow.d.ts +18 -0
- package/dist/integrations/indexnow.js +38 -0
- package/dist/integrations/search-console.d.ts +25 -0
- package/dist/integrations/search-console.js +39 -0
- package/dist/integrations/tags.d.ts +22 -0
- package/dist/integrations/tags.js +67 -0
- package/dist/seo/head.d.ts +20 -0
- package/dist/seo/head.js +85 -0
- package/dist/seo/jsonld.d.ts +35 -0
- package/dist/seo/jsonld.js +31 -0
- package/dist/seo/urls.d.ts +3 -0
- package/dist/seo/urls.js +10 -0
- package/package.json +81 -4
- package/templates/starter/.env.example +12 -0
- package/templates/starter/.github/dependabot.yml +11 -0
- package/templates/starter/.github/workflows/ci.yml +30 -0
- package/templates/starter/.github/workflows/verify.yml +32 -0
- package/templates/starter/AGENTS.md +166 -0
- package/templates/starter/CLAUDE.md +3 -0
- package/templates/starter/_gitignore +8 -0
- package/templates/starter/links.lock.json +4 -0
- package/templates/starter/package.json +16 -0
- package/templates/starter/public/apple-touch-icon.png +0 -0
- package/templates/starter/public/favicon.ico +0 -0
- package/templates/starter/public/favicon.svg +1 -0
- package/templates/starter/src/layouts/blog.ts +13 -0
- package/templates/starter/src/layouts/default.ts +38 -0
- package/templates/starter/src/pages/404.md +7 -0
- package/templates/starter/src/pages/about.md +8 -0
- package/templates/starter/src/pages/blog/first-post.md +10 -0
- package/templates/starter/src/pages/blog/index.md +7 -0
- package/templates/starter/src/pages/index.md +8 -0
- package/templates/starter/vage.config.ts +38 -0
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
inlineStyles: z.ZodDefault<z.ZodBoolean>;
|
|
14
|
+
}, z.core.$strict>>;
|
|
15
|
+
seo: z.ZodDefault<z.ZodObject<{
|
|
16
|
+
robots: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
17
|
+
rules: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
18
|
+
userAgent: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
|
|
19
|
+
allow: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
20
|
+
disallow: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
21
|
+
}, z.core.$strict>>>;
|
|
22
|
+
aiTraining: z.ZodOptional<z.ZodEnum<{
|
|
23
|
+
allow: "allow";
|
|
24
|
+
disallow: "disallow";
|
|
25
|
+
}>>;
|
|
26
|
+
aiSearch: z.ZodOptional<z.ZodEnum<{
|
|
27
|
+
allow: "allow";
|
|
28
|
+
disallow: "disallow";
|
|
29
|
+
}>>;
|
|
30
|
+
}, z.core.$strict>]>>, z.ZodTransform<false | {
|
|
31
|
+
rules: {
|
|
32
|
+
userAgent: string | string[];
|
|
33
|
+
allow: string[];
|
|
34
|
+
disallow: string[];
|
|
35
|
+
}[];
|
|
36
|
+
aiTraining?: "allow" | "disallow" | undefined;
|
|
37
|
+
aiSearch?: "allow" | "disallow" | undefined;
|
|
38
|
+
}, boolean | {
|
|
39
|
+
rules: {
|
|
40
|
+
userAgent: string | string[];
|
|
41
|
+
allow: string[];
|
|
42
|
+
disallow: string[];
|
|
43
|
+
}[];
|
|
44
|
+
aiTraining?: "allow" | "disallow" | undefined;
|
|
45
|
+
aiSearch?: "allow" | "disallow" | undefined;
|
|
46
|
+
}>>;
|
|
47
|
+
sitemap: z.ZodDefault<z.ZodBoolean>;
|
|
48
|
+
llmsTxt: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
49
|
+
full: z.ZodDefault<z.ZodBoolean>;
|
|
50
|
+
}, z.core.$strict>]>>, z.ZodTransform<false | {
|
|
51
|
+
full: boolean;
|
|
52
|
+
}, boolean | {
|
|
53
|
+
full: boolean;
|
|
54
|
+
}>>;
|
|
55
|
+
structuredData: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
56
|
+
organization: z.ZodOptional<z.ZodObject<{
|
|
57
|
+
name: z.ZodString;
|
|
58
|
+
url: z.ZodOptional<z.ZodURL>;
|
|
59
|
+
logo: z.ZodOptional<z.ZodString>;
|
|
60
|
+
sameAs: z.ZodOptional<z.ZodArray<z.ZodURL>>;
|
|
61
|
+
}, z.core.$strict>>;
|
|
62
|
+
}, z.core.$strict>]>>, z.ZodTransform<false | {
|
|
63
|
+
organization?: {
|
|
64
|
+
name: string;
|
|
65
|
+
url?: string | undefined;
|
|
66
|
+
logo?: string | undefined;
|
|
67
|
+
sameAs?: string[] | undefined;
|
|
68
|
+
} | undefined;
|
|
69
|
+
}, boolean | {
|
|
70
|
+
organization?: {
|
|
71
|
+
name: string;
|
|
72
|
+
url?: string | undefined;
|
|
73
|
+
logo?: string | undefined;
|
|
74
|
+
sameAs?: string[] | undefined;
|
|
75
|
+
} | undefined;
|
|
76
|
+
}>>;
|
|
77
|
+
}, z.core.$strict>>;
|
|
78
|
+
performance: z.ZodDefault<z.ZodObject<{
|
|
79
|
+
budgets: z.ZodDefault<z.ZodObject<{
|
|
80
|
+
performance: z.ZodDefault<z.ZodNumber>;
|
|
81
|
+
accessibility: z.ZodDefault<z.ZodNumber>;
|
|
82
|
+
seo: z.ZodDefault<z.ZodNumber>;
|
|
83
|
+
bestPractices: z.ZodDefault<z.ZodNumber>;
|
|
84
|
+
javascriptKb: z.ZodDefault<z.ZodNumber>;
|
|
85
|
+
cssKb: z.ZodDefault<z.ZodNumber>;
|
|
86
|
+
imageKb: z.ZodDefault<z.ZodNumber>;
|
|
87
|
+
fontKb: z.ZodDefault<z.ZodNumber>;
|
|
88
|
+
totalKb: z.ZodDefault<z.ZodNumber>;
|
|
89
|
+
requests: z.ZodDefault<z.ZodNumber>;
|
|
90
|
+
}, z.core.$strict>>;
|
|
91
|
+
lighthouse: z.ZodDefault<z.ZodObject<{
|
|
92
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
93
|
+
pages: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
94
|
+
}, z.core.$strict>>;
|
|
95
|
+
}, z.core.$strict>>;
|
|
96
|
+
deploy: z.ZodDefault<z.ZodObject<{
|
|
97
|
+
target: z.ZodDefault<z.ZodEnum<{
|
|
98
|
+
"cloudflare-pages": "cloudflare-pages";
|
|
99
|
+
"cloudflare-workers": "cloudflare-workers";
|
|
100
|
+
"github-pages": "github-pages";
|
|
101
|
+
}>>;
|
|
102
|
+
cloudflareProject: z.ZodOptional<z.ZodString>;
|
|
103
|
+
cloudflareWorker: z.ZodOptional<z.ZodString>;
|
|
104
|
+
}, z.core.$strict>>;
|
|
105
|
+
security: z.ZodDefault<z.ZodObject<{
|
|
106
|
+
headers: z.ZodDefault<z.ZodBoolean>;
|
|
107
|
+
csp: z.ZodDefault<z.ZodEnum<{
|
|
108
|
+
enforce: "enforce";
|
|
109
|
+
off: "off";
|
|
110
|
+
"report-only": "report-only";
|
|
111
|
+
}>>;
|
|
112
|
+
cspSources: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
|
113
|
+
"connect-src": "connect-src";
|
|
114
|
+
"font-src": "font-src";
|
|
115
|
+
"frame-src": "frame-src";
|
|
116
|
+
"img-src": "img-src";
|
|
117
|
+
"media-src": "media-src";
|
|
118
|
+
"script-src": "script-src";
|
|
119
|
+
"style-src": "style-src";
|
|
120
|
+
}> & z.core.$partial, z.ZodArray<z.ZodString>>>;
|
|
121
|
+
outboundLinks: z.ZodDefault<z.ZodEnum<{
|
|
122
|
+
lock: "lock";
|
|
123
|
+
off: "off";
|
|
124
|
+
require: "require";
|
|
125
|
+
}>>;
|
|
126
|
+
spamPatterns: z.ZodDefault<z.ZodBoolean>;
|
|
127
|
+
}, z.core.$strict>>;
|
|
128
|
+
guidelines: z.ZodDefault<z.ZodObject<{
|
|
129
|
+
rulePack: z.ZodOptional<z.ZodString>;
|
|
130
|
+
}, z.core.$strict>>;
|
|
131
|
+
integrations: z.ZodDefault<z.ZodObject<{
|
|
132
|
+
ga4: z.ZodOptional<z.ZodObject<{
|
|
133
|
+
measurementId: z.ZodOptional<z.ZodString>;
|
|
134
|
+
propertyId: z.ZodOptional<z.ZodString>;
|
|
135
|
+
consentDefault: z.ZodDefault<z.ZodEnum<{
|
|
136
|
+
denied: "denied";
|
|
137
|
+
granted: "granted";
|
|
138
|
+
}>>;
|
|
139
|
+
events: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
140
|
+
params: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
141
|
+
}, z.core.$strict>>>;
|
|
142
|
+
}, z.core.$strict>>;
|
|
143
|
+
gtm: z.ZodOptional<z.ZodObject<{
|
|
144
|
+
containerId: z.ZodOptional<z.ZodString>;
|
|
145
|
+
consentDefault: z.ZodDefault<z.ZodEnum<{
|
|
146
|
+
denied: "denied";
|
|
147
|
+
granted: "granted";
|
|
148
|
+
}>>;
|
|
149
|
+
events: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
150
|
+
params: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
151
|
+
}, z.core.$strict>>>;
|
|
152
|
+
}, z.core.$strict>>;
|
|
153
|
+
plausible: z.ZodOptional<z.ZodObject<{
|
|
154
|
+
snippet: z.ZodString;
|
|
155
|
+
events: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
156
|
+
params: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
157
|
+
}, z.core.$strict>>>;
|
|
158
|
+
}, z.core.$strict>>;
|
|
159
|
+
clarity: z.ZodOptional<z.ZodObject<{
|
|
160
|
+
snippet: z.ZodString;
|
|
161
|
+
}, z.core.$strict>>;
|
|
162
|
+
indexNow: z.ZodOptional<z.ZodObject<{
|
|
163
|
+
key: z.ZodString;
|
|
164
|
+
endpoint: z.ZodDefault<z.ZodURL>;
|
|
165
|
+
}, z.core.$strict>>;
|
|
166
|
+
searchConsole: z.ZodOptional<z.ZodObject<{
|
|
167
|
+
property: z.ZodOptional<z.ZodString>;
|
|
168
|
+
}, z.core.$strict>>;
|
|
169
|
+
}, z.core.$strict>>;
|
|
170
|
+
redirects: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
171
|
+
}, z.core.$strict>;
|
|
172
|
+
export type UserConfig = z.input<typeof configSchema>;
|
|
173
|
+
export type SiteConfig = z.output<typeof configSchema>;
|
|
174
|
+
/** Identity helper that gives config files type checking. */
|
|
175
|
+
export declare function defineConfig(config: UserConfig): UserConfig;
|
|
176
|
+
export declare function findConfigFile(root: string): string | undefined;
|
|
177
|
+
export type ModuleLoader = (file: string) => Promise<Record<string, unknown>>;
|
|
178
|
+
export declare function loadConfig(root: string, load: ModuleLoader): Promise<SiteConfig>;
|
|
179
|
+
/** Validates a raw config object and applies defaults. */
|
|
180
|
+
export declare function parseConfig(raw: unknown, file?: string): SiteConfig;
|
|
@@ -0,0 +1,260 @@
|
|
|
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
|
+
inlineStyles: z
|
|
181
|
+
.boolean()
|
|
182
|
+
.default(false)
|
|
183
|
+
.describe('Keep style="..." attributes in page content (sanitize mode). Off by default: inline styles are the usual way to hide spam links. Put styling in layouts.'),
|
|
184
|
+
})
|
|
185
|
+
.default({ html: "sanitize", inlineStyles: false }),
|
|
186
|
+
seo: seoSchema.default(() => seoSchema.parse({})),
|
|
187
|
+
performance: performanceSchema.default(() => performanceSchema.parse({})),
|
|
188
|
+
deploy: z
|
|
189
|
+
.strictObject({
|
|
190
|
+
target: z
|
|
191
|
+
.enum(["github-pages", "cloudflare-pages", "cloudflare-workers"])
|
|
192
|
+
.default("github-pages")
|
|
193
|
+
.describe("Host used by `vage deploy`. Cloudflare now creates new sites as Workers: prefer cloudflare-workers there."),
|
|
194
|
+
cloudflareProject: z.string().regex(/^[a-z0-9][a-z0-9-]{0,57}$/).optional().describe("Cloudflare Pages project name."),
|
|
195
|
+
cloudflareWorker: z
|
|
196
|
+
.string()
|
|
197
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,62}$/)
|
|
198
|
+
.optional()
|
|
199
|
+
.describe("Cloudflare Worker name (cloudflare-workers target, when the project has no wrangler config). Defaults to cloudflareProject."),
|
|
200
|
+
})
|
|
201
|
+
.default({ target: "github-pages" }),
|
|
202
|
+
security: z
|
|
203
|
+
.strictObject({
|
|
204
|
+
headers: z.boolean().default(true).describe("Write a _headers file with security headers (Cloudflare targets)."),
|
|
205
|
+
csp: z
|
|
206
|
+
.enum(["enforce", "report-only", "off"])
|
|
207
|
+
.default("enforce")
|
|
208
|
+
.describe("Content Security Policy generated from the built pages: hashes of inline scripts/styles plus the origins your layouts and analytics load."),
|
|
209
|
+
cspSources: z
|
|
210
|
+
.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'")))
|
|
211
|
+
.default({})
|
|
212
|
+
.describe("Extra sources per directive, e.g. { \"connect-src\": [\"https://api.example.com\"] }."),
|
|
213
|
+
outboundLinks: z
|
|
214
|
+
.enum(["require", "lock", "off"])
|
|
215
|
+
.default("lock")
|
|
216
|
+
.describe('"lock": when links.lock.json exists, every external domain the site links to must be listed there (approve with `vage links approve`); new domains fail `vage check`. Catches injected spam links. "require": the same, and a missing links.lock.json is an error, so deleting it can\'t switch the guard off. "off": no guard.'),
|
|
217
|
+
spamPatterns: z.boolean().default(true).describe("Warn in `vage check` about common spam words (casino, pills, payday loans...) in page text and links."),
|
|
218
|
+
})
|
|
219
|
+
.default({ headers: true, csp: "enforce", cspSources: {}, outboundLinks: "lock", spamPatterns: true }),
|
|
220
|
+
guidelines: z
|
|
221
|
+
.strictObject({
|
|
222
|
+
rulePack: z
|
|
223
|
+
.string()
|
|
224
|
+
.regex(/^\d{4}\.\d{1,2}\.\d+$/)
|
|
225
|
+
.optional()
|
|
226
|
+
.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."),
|
|
227
|
+
})
|
|
228
|
+
.default({}),
|
|
229
|
+
integrations: integrationsSchema.default({}).describe("Optional analytics and search integrations. All off unless configured."),
|
|
230
|
+
redirects: z
|
|
231
|
+
.record(z.string(), redirectTarget)
|
|
232
|
+
.superRefine((redirects, ctx) => {
|
|
233
|
+
for (const from of Object.keys(redirects)) {
|
|
234
|
+
const result = redirectSource.safeParse(from);
|
|
235
|
+
if (!result.success)
|
|
236
|
+
ctx.addIssue({ code: "custom", path: [from], message: `redirect source "${from}" ${result.error.issues[0].message}` });
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
.default({})
|
|
240
|
+
.describe("Old path -> new path or URL. Built as noindex meta-refresh pages; dev answers 301."),
|
|
241
|
+
});
|
|
242
|
+
/** Identity helper that gives config files type checking. */
|
|
243
|
+
export function defineConfig(config) {
|
|
244
|
+
return config;
|
|
245
|
+
}
|
|
246
|
+
export function findConfigFile(root) {
|
|
247
|
+
return CONFIG_FILES.map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
|
|
248
|
+
}
|
|
249
|
+
export async function loadConfig(root, load) {
|
|
250
|
+
const file = findConfigFile(root);
|
|
251
|
+
return parseConfig(file ? (await load(file)).default : {}, file);
|
|
252
|
+
}
|
|
253
|
+
/** Validates a raw config object and applies defaults. */
|
|
254
|
+
export function parseConfig(raw, file) {
|
|
255
|
+
const result = configSchema.safeParse(raw ?? {});
|
|
256
|
+
if (!result.success) {
|
|
257
|
+
throw new VageError(`Invalid config:\n${z.prettifyError(result.error)}`, file);
|
|
258
|
+
}
|
|
259
|
+
return result.data;
|
|
260
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** An expected, user-facing error. The CLI prints its message without a stack trace. */
|
|
2
|
+
export declare class VageError extends Error {
|
|
3
|
+
readonly file?: string | undefined;
|
|
4
|
+
/** The message without the "at <file>" suffix. */
|
|
5
|
+
readonly detail: string;
|
|
6
|
+
constructor(message: string, file?: string | undefined);
|
|
7
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** An expected, user-facing error. The CLI prints its message without a stack trace. */
|
|
2
|
+
export class VageError extends Error {
|
|
3
|
+
file;
|
|
4
|
+
/** The message without the "at <file>" suffix. */
|
|
5
|
+
detail;
|
|
6
|
+
constructor(message, file) {
|
|
7
|
+
super(file ? `${message}\n at ${file}` : message);
|
|
8
|
+
this.file = file;
|
|
9
|
+
this.detail = message;
|
|
10
|
+
this.name = "VageError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Escapes text for safe use in HTML content and attribute values. */
|
|
2
|
+
export declare function escapeHtml(value: unknown): string;
|
|
3
|
+
declare const SAFE: unique symbol;
|
|
4
|
+
/** HTML that `html` inserts as-is. Create it with `html` or `raw()`; `String(x)` gives the markup. */
|
|
5
|
+
export interface SafeHtml {
|
|
6
|
+
readonly [SAFE]: true;
|
|
7
|
+
readonly value: string;
|
|
8
|
+
toString(): string;
|
|
9
|
+
}
|
|
10
|
+
export declare const isSafeHtml: (v: unknown) => v is SafeHtml;
|
|
11
|
+
/**
|
|
12
|
+
* Marks trusted markup so `html` won't escape it, such as `props.content` (already
|
|
13
|
+
* sanitized) or a JSON-LD script from `jsonLdScript()`. Never pass user or frontmatter text.
|
|
14
|
+
*/
|
|
15
|
+
export declare function raw(markup: string): SafeHtml;
|
|
16
|
+
/**
|
|
17
|
+
* Template tag for layouts: every `${value}` is escaped unless it is `html` itself or `raw()`.
|
|
18
|
+
* Arrays are joined, and null, undefined and false render nothing, so lists and conditions read naturally:
|
|
19
|
+
*
|
|
20
|
+
* html`<ul>${posts.map((p) => html`<li><a href="${p.route}">${p.title}</a></li>`)}</ul>`
|
|
21
|
+
* html`<main>${raw(content)}</main>`
|
|
22
|
+
*/
|
|
23
|
+
export declare function html(strings: TemplateStringsArray, ...values: unknown[]): SafeHtml;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
}
|
|
12
|
+
const SAFE = Symbol.for("vagejs.safeHtml");
|
|
13
|
+
const safe = (value) => ({ [SAFE]: true, value, toString: () => value });
|
|
14
|
+
export const isSafeHtml = (v) => typeof v === "object" && v !== null && SAFE in v;
|
|
15
|
+
/**
|
|
16
|
+
* Marks trusted markup so `html` won't escape it, such as `props.content` (already
|
|
17
|
+
* sanitized) or a JSON-LD script from `jsonLdScript()`. Never pass user or frontmatter text.
|
|
18
|
+
*/
|
|
19
|
+
export function raw(markup) {
|
|
20
|
+
return safe(String(markup));
|
|
21
|
+
}
|
|
22
|
+
function interpolate(v) {
|
|
23
|
+
if (isSafeHtml(v))
|
|
24
|
+
return v.value;
|
|
25
|
+
if (Array.isArray(v))
|
|
26
|
+
return v.map(interpolate).join("");
|
|
27
|
+
if (v === null || v === undefined || v === false)
|
|
28
|
+
return "";
|
|
29
|
+
return escapeHtml(v);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Template tag for layouts: every `${value}` is escaped unless it is `html` itself or `raw()`.
|
|
33
|
+
* Arrays are joined, and null, undefined and false render nothing, so lists and conditions read naturally:
|
|
34
|
+
*
|
|
35
|
+
* html`<ul>${posts.map((p) => html`<li><a href="${p.route}">${p.title}</a></li>`)}</ul>`
|
|
36
|
+
* html`<main>${raw(content)}</main>`
|
|
37
|
+
*/
|
|
38
|
+
export function html(strings, ...values) {
|
|
39
|
+
let out = strings[0];
|
|
40
|
+
values.forEach((v, i) => (out += interpolate(v) + strings[i + 1]));
|
|
41
|
+
return safe(out);
|
|
42
|
+
}
|
|
@@ -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,40 @@
|
|
|
1
|
+
import { type Frontmatter } from "../content/markdown.js";
|
|
2
|
+
import type { ModuleLoader, SiteConfig } from "./config.js";
|
|
3
|
+
/** One published page, as layouts see it in `props.pages`. */
|
|
4
|
+
export interface PageSummary {
|
|
5
|
+
/** URL path with a trailing slash, e.g. "/blog/first-post/". */
|
|
6
|
+
route: string;
|
|
7
|
+
/** Absolute canonical URL, when `site` is set and the page is indexable. */
|
|
8
|
+
url?: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Source path relative to the project root, e.g. "src/pages/blog/first-post.md". */
|
|
12
|
+
source: string;
|
|
13
|
+
frontmatter: Frontmatter;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
|
|
17
|
+
* and applies each page's layout schema, so list entries get the same defaults as the
|
|
18
|
+
* page itself. The result is frozen: layouts share it, so none may change it.
|
|
19
|
+
* `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
|
|
20
|
+
* they report their own error instead of breaking every page that lists others.
|
|
21
|
+
*/
|
|
22
|
+
export declare function collectPages(root: string, site: SiteConfig, load: ModuleLoader, options?: {
|
|
23
|
+
skipInvalid?: boolean;
|
|
24
|
+
}): Promise<readonly PageSummary[]>;
|
|
25
|
+
export interface CollectionOptions {
|
|
26
|
+
/** Only pages below this route, e.g. "/blog/". The section's own index page is left out unless `includeIndex`. */
|
|
27
|
+
under?: string;
|
|
28
|
+
includeIndex?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* "date": newest `datePublished` first (undated pages last, then by title).
|
|
31
|
+
* "title": A to Z. "route" (default): by URL. Or a compare function.
|
|
32
|
+
*/
|
|
33
|
+
sort?: "date" | "title" | "route" | ((a: PageSummary, b: PageSummary) => number);
|
|
34
|
+
limit?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
|
|
38
|
+
* collection(pages, { under: "/blog/", sort: "date", limit: 5 })
|
|
39
|
+
*/
|
|
40
|
+
export declare function collection(pages: readonly PageSummary[], options?: CollectionOptions): PageSummary[];
|