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,123 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { VageError } from "../core/errors.js";
|
|
3
|
+
import { apiError, redact, requestWithRetry } from "../integrations/http.js";
|
|
4
|
+
import { rate } from "./vitals.js";
|
|
5
|
+
const PSI_ENDPOINT = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
|
|
6
|
+
const CRUX_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
|
|
7
|
+
const KEY_HINTS = {
|
|
8
|
+
400: "Check the URL is public and the API key has this API enabled.",
|
|
9
|
+
403: "Check the URL is public and the API key has this API enabled.",
|
|
10
|
+
429: "Rate limited: wait and retry, or use an API key with quota.",
|
|
11
|
+
};
|
|
12
|
+
/** The API key goes in the x-goog-api-key header, never in the URL. */
|
|
13
|
+
function withKey(init, key) {
|
|
14
|
+
return { ...init, headers: { ...init.headers, ...(key ? { "x-goog-api-key": key } : {}) } };
|
|
15
|
+
}
|
|
16
|
+
export { redact };
|
|
17
|
+
// ---------------------------------------------------------------- PageSpeed Insights (lab)
|
|
18
|
+
const CATEGORY_IDS = ["performance", "accessibility", "seo", "best-practices"];
|
|
19
|
+
const psiSchema = z.object({
|
|
20
|
+
lighthouseResult: z.object({
|
|
21
|
+
lighthouseVersion: z.string(),
|
|
22
|
+
fetchTime: z.string(),
|
|
23
|
+
finalDisplayedUrl: z.string().optional(),
|
|
24
|
+
categories: z.record(z.string(), z.object({ score: z.number().nullable() })),
|
|
25
|
+
audits: z.record(z.string(), z.object({ title: z.string(), score: z.number().nullable().optional(), numericValue: z.number().optional(), displayValue: z.string().optional() })),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
/** Normalizes a Lighthouse result (from PSI or a local run). */
|
|
29
|
+
export function labReport(lhr, source, url, strategy) {
|
|
30
|
+
const score = (id) => {
|
|
31
|
+
const s = lhr.categories[id]?.score;
|
|
32
|
+
return s === null || s === undefined ? null : Math.round(s * 100);
|
|
33
|
+
};
|
|
34
|
+
const num = (id) => lhr.audits[id]?.numericValue;
|
|
35
|
+
const opportunities = Object.entries(lhr.audits)
|
|
36
|
+
.filter(([, a]) => typeof a.score === "number" && a.score < 0.9 && a.displayValue)
|
|
37
|
+
.sort(([, a], [, b]) => (a.score ?? 1) - (b.score ?? 1))
|
|
38
|
+
.slice(0, 8)
|
|
39
|
+
.map(([id, a]) => ({ id, title: a.title, displayValue: a.displayValue }));
|
|
40
|
+
return {
|
|
41
|
+
source,
|
|
42
|
+
url,
|
|
43
|
+
strategy,
|
|
44
|
+
fetchTime: lhr.fetchTime,
|
|
45
|
+
toolVersion: `lighthouse ${lhr.lighthouseVersion}`,
|
|
46
|
+
scores: {
|
|
47
|
+
performance: score("performance"),
|
|
48
|
+
accessibility: score("accessibility"),
|
|
49
|
+
seo: score("seo"),
|
|
50
|
+
"best-practices": score("best-practices"),
|
|
51
|
+
},
|
|
52
|
+
metrics: {
|
|
53
|
+
lcpMs: num("largest-contentful-paint"),
|
|
54
|
+
cls: num("cumulative-layout-shift"),
|
|
55
|
+
tbtMs: num("total-blocking-time"),
|
|
56
|
+
fcpMs: num("first-contentful-paint"),
|
|
57
|
+
speedIndexMs: num("speed-index"),
|
|
58
|
+
},
|
|
59
|
+
opportunities,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export async function pageSpeed(url, strategy, key, opts = {}) {
|
|
63
|
+
const params = new URLSearchParams({ url, strategy: strategy.toUpperCase() });
|
|
64
|
+
for (const c of CATEGORY_IDS)
|
|
65
|
+
params.append("category", c.toUpperCase().replace("-", "_"));
|
|
66
|
+
const res = await requestWithRetry("PageSpeed Insights", `${PSI_ENDPOINT}?${params}`, withKey({ method: "GET" }, key), [key], opts);
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
throw await apiError("PageSpeed Insights", res, [key], KEY_HINTS);
|
|
69
|
+
const parsed = psiSchema.safeParse(await res.json().catch(() => undefined));
|
|
70
|
+
if (!parsed.success)
|
|
71
|
+
throw new VageError("PageSpeed Insights returned an unexpected response (no lighthouseResult).");
|
|
72
|
+
return labReport(parsed.data.lighthouseResult, "pagespeed-insights", url, strategy);
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------- CrUX (field)
|
|
75
|
+
const cruxSchema = z.object({
|
|
76
|
+
record: z.object({
|
|
77
|
+
metrics: z.record(z.string(), z.object({ percentiles: z.object({ p75: z.union([z.number(), z.string()]) }) })),
|
|
78
|
+
collectionPeriod: z
|
|
79
|
+
.object({
|
|
80
|
+
firstDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
|
|
81
|
+
lastDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
|
|
82
|
+
})
|
|
83
|
+
.optional(),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const CRUX_METRICS = {
|
|
87
|
+
largest_contentful_paint: "lcp",
|
|
88
|
+
interaction_to_next_paint: "inp",
|
|
89
|
+
cumulative_layout_shift: "cls",
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Queries the CrUX API for the page, falling back to the origin. Returns
|
|
93
|
+
* undefined when neither has data. A 404 is treated as "no data"; the API
|
|
94
|
+
* docs do not specify this response, so it is an observed-behavior assumption.
|
|
95
|
+
*/
|
|
96
|
+
export async function cruxField(url, strategy, key, opts = {}) {
|
|
97
|
+
const formFactor = strategy === "mobile" ? "PHONE" : "DESKTOP";
|
|
98
|
+
for (const scope of ["url", "origin"]) {
|
|
99
|
+
const body = { [scope]: scope === "url" ? url : new URL(url).origin, formFactor, metrics: Object.keys(CRUX_METRICS) };
|
|
100
|
+
const init = withKey({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, key);
|
|
101
|
+
const res = await requestWithRetry("CrUX API", CRUX_ENDPOINT, init, [key], opts);
|
|
102
|
+
if (res.status === 404)
|
|
103
|
+
continue;
|
|
104
|
+
if (!res.ok)
|
|
105
|
+
throw await apiError("CrUX API", res, [key], KEY_HINTS);
|
|
106
|
+
const parsed = cruxSchema.safeParse(await res.json().catch(() => undefined));
|
|
107
|
+
if (!parsed.success)
|
|
108
|
+
throw new VageError("CrUX API returned an unexpected response (no record.metrics).");
|
|
109
|
+
const metrics = {};
|
|
110
|
+
for (const [apiName, name] of Object.entries(CRUX_METRICS)) {
|
|
111
|
+
const raw = parsed.data.record.metrics[apiName]?.percentiles.p75;
|
|
112
|
+
if (raw === undefined)
|
|
113
|
+
continue;
|
|
114
|
+
const p75 = Number(raw); // CLS is a string-encoded double
|
|
115
|
+
if (Number.isFinite(p75))
|
|
116
|
+
metrics[name] = { p75, rating: rate(name, p75) };
|
|
117
|
+
}
|
|
118
|
+
const period = parsed.data.record.collectionPeriod;
|
|
119
|
+
const date = (d) => `${d.year}-${String(d.month).padStart(2, "0")}-${String(d.day).padStart(2, "0")}`;
|
|
120
|
+
return { source: "crux", scope, formFactor, metrics, collectionPeriod: period && `${date(period.firstDate)} to ${date(period.lastDate)}` };
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Web Vitals thresholds, assessed at the 75th percentile of real-user
|
|
3
|
+
* page loads (rule pack "vitals.thresholds", source: https://web.dev/articles/vitals).
|
|
4
|
+
*/
|
|
5
|
+
export declare const VITALS: {
|
|
6
|
+
readonly lcp: {
|
|
7
|
+
readonly good: number;
|
|
8
|
+
readonly poor: number;
|
|
9
|
+
readonly label: "LCP";
|
|
10
|
+
readonly unit: "ms";
|
|
11
|
+
};
|
|
12
|
+
readonly inp: {
|
|
13
|
+
readonly good: number;
|
|
14
|
+
readonly poor: number;
|
|
15
|
+
readonly label: "INP";
|
|
16
|
+
readonly unit: "ms";
|
|
17
|
+
};
|
|
18
|
+
readonly cls: {
|
|
19
|
+
readonly good: number;
|
|
20
|
+
readonly poor: number;
|
|
21
|
+
readonly label: "CLS";
|
|
22
|
+
readonly unit: "";
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
export type VitalName = keyof typeof VITALS;
|
|
26
|
+
export type Rating = "good" | "needs-improvement" | "poor";
|
|
27
|
+
export declare function rate(metric: VitalName, p75: number): Rating;
|
|
28
|
+
export declare const LAB_VS_FIELD: string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ruleData } from "../guidelines/pack.js";
|
|
2
|
+
const T = ruleData("vitals.thresholds");
|
|
3
|
+
/**
|
|
4
|
+
* Core Web Vitals thresholds, assessed at the 75th percentile of real-user
|
|
5
|
+
* page loads (rule pack "vitals.thresholds", source: https://web.dev/articles/vitals).
|
|
6
|
+
*/
|
|
7
|
+
export const VITALS = {
|
|
8
|
+
lcp: { label: "LCP", unit: "ms", ...T.lcp },
|
|
9
|
+
inp: { label: "INP", unit: "ms", ...T.inp },
|
|
10
|
+
cls: { label: "CLS", unit: "", ...T.cls },
|
|
11
|
+
};
|
|
12
|
+
export function rate(metric, p75) {
|
|
13
|
+
const t = VITALS[metric];
|
|
14
|
+
return p75 <= t.good ? "good" : p75 <= t.poor ? "needs-improvement" : "poor";
|
|
15
|
+
}
|
|
16
|
+
export const LAB_VS_FIELD = "Lab results (Lighthouse/PageSpeed) are a single simulated load and vary between runs. " +
|
|
17
|
+
"Field data (CrUX) reflects real Chrome users at the 75th percentile over 28 days and is " +
|
|
18
|
+
"unavailable for pages or sites without enough traffic. Google's Core Web Vitals assessment uses field data.";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type SiteModel } from "../core/site.js";
|
|
2
|
+
export type Severity = "error" | "warning";
|
|
3
|
+
export interface Finding {
|
|
4
|
+
rule: string;
|
|
5
|
+
severity: Severity;
|
|
6
|
+
message: string;
|
|
7
|
+
/** Route of the page the finding is about, if any. */
|
|
8
|
+
page?: string;
|
|
9
|
+
/** Source file relative to the project root, if any. */
|
|
10
|
+
source?: string;
|
|
11
|
+
/** Official documentation backing the rule, where one exists. */
|
|
12
|
+
docs?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface CheckResult {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
errors: number;
|
|
17
|
+
warnings: number;
|
|
18
|
+
pagesChecked: number;
|
|
19
|
+
findings: Finding[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Static checks on the rendered site (nothing is written to disk).
|
|
23
|
+
* Passing checks mean the output is technically sound; they do not guarantee
|
|
24
|
+
* indexing, rankings, rich results or AI citations.
|
|
25
|
+
*/
|
|
26
|
+
export declare function check(root: string, options?: {
|
|
27
|
+
strict?: boolean;
|
|
28
|
+
}): Promise<CheckResult>;
|
|
29
|
+
export declare function checkSite(root: string, model: SiteModel): Finding[];
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "node-html-parser";
|
|
4
|
+
import { createBuildLoader } from "../core/loader.js";
|
|
5
|
+
import { listedPages, renderSite } from "../core/site.js";
|
|
6
|
+
const DOCS = {
|
|
7
|
+
title: "https://developers.google.com/search/docs/appearance/title-link",
|
|
8
|
+
description: "https://developers.google.com/search/docs/appearance/snippet",
|
|
9
|
+
canonical: "https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls",
|
|
10
|
+
structuredData: "https://developers.google.com/search/docs/appearance/structured-data/sd-policies",
|
|
11
|
+
article: "https://developers.google.com/search/docs/appearance/structured-data/article",
|
|
12
|
+
imgAlt: "https://www.w3.org/WAI/tutorials/images/",
|
|
13
|
+
lang: "https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html",
|
|
14
|
+
linkPurpose: "https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html",
|
|
15
|
+
robots: "https://developers.google.com/search/docs/crawling-indexing/robots/intro",
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Static checks on the rendered site (nothing is written to disk).
|
|
19
|
+
* Passing checks mean the output is technically sound; they do not guarantee
|
|
20
|
+
* indexing, rankings, rich results or AI citations.
|
|
21
|
+
*/
|
|
22
|
+
export async function check(root, options = {}) {
|
|
23
|
+
root = path.resolve(root);
|
|
24
|
+
const loader = await createBuildLoader(root);
|
|
25
|
+
try {
|
|
26
|
+
const model = await renderSite(root, loader.load);
|
|
27
|
+
const findings = checkSite(root, model);
|
|
28
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
29
|
+
const warnings = findings.length - errors;
|
|
30
|
+
return {
|
|
31
|
+
ok: errors === 0 && (!options.strict || warnings === 0),
|
|
32
|
+
errors,
|
|
33
|
+
warnings,
|
|
34
|
+
pagesChecked: model.pages.length + (model.notFound ? 1 : 0),
|
|
35
|
+
findings,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await loader.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function checkSite(root, model) {
|
|
43
|
+
const findings = model.warnings.map((message) => ({ rule: "config", severity: "warning", message }));
|
|
44
|
+
const outputs = outputPaths(root, model);
|
|
45
|
+
const drafts = new Set(model.draftRoutes);
|
|
46
|
+
const pages = model.notFound ? [...model.pages, model.notFound] : model.pages;
|
|
47
|
+
for (const page of pages) {
|
|
48
|
+
const add = (f) => findings.push({ ...f, page: page.route, source: page.source });
|
|
49
|
+
checkPage(page, parse(page.html), model, outputs, drafts, add);
|
|
50
|
+
}
|
|
51
|
+
findings.push(...checkDuplicates(listedPages(model)));
|
|
52
|
+
findings.push(...checkRobots(model));
|
|
53
|
+
return findings;
|
|
54
|
+
}
|
|
55
|
+
function checkPage(page, doc, model, outputs, drafts, add) {
|
|
56
|
+
const indexable = !page.meta.noindex;
|
|
57
|
+
if (!doc.querySelector("html")?.getAttribute("lang")) {
|
|
58
|
+
add({ rule: "html-lang", severity: "error", message: "<html> has no lang attribute.", docs: DOCS.lang });
|
|
59
|
+
}
|
|
60
|
+
const titles = doc.querySelectorAll("title");
|
|
61
|
+
if (titles.length !== 1 || !titles[0].text.trim()) {
|
|
62
|
+
add({ rule: "title", severity: "error", message: `Expected exactly one non-empty <title>, found ${titles.length}.`, docs: DOCS.title });
|
|
63
|
+
}
|
|
64
|
+
if (indexable && !doc.querySelector('meta[name="description"]')?.getAttribute("content")) {
|
|
65
|
+
add({
|
|
66
|
+
rule: "meta-description",
|
|
67
|
+
severity: "warning",
|
|
68
|
+
message: "No meta description. Add `description` to the frontmatter or site config.",
|
|
69
|
+
docs: DOCS.description,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (!doc.querySelector("h1")) {
|
|
73
|
+
add({ rule: "h1", severity: "warning", message: "Page has no <h1> heading." });
|
|
74
|
+
}
|
|
75
|
+
for (const img of doc.querySelectorAll("img")) {
|
|
76
|
+
const src = img.getAttribute("src") ?? "";
|
|
77
|
+
if (img.getAttribute("alt") === undefined) {
|
|
78
|
+
add({ rule: "img-alt", severity: "error", message: `<img src="${src}"> has no alt attribute (use alt="" for decorative images).`, docs: DOCS.imgAlt });
|
|
79
|
+
}
|
|
80
|
+
if (!img.getAttribute("width") || !img.getAttribute("height")) {
|
|
81
|
+
add({ rule: "img-dimensions", severity: "warning", message: `<img src="${src}"> has no width/height; this can cause layout shift.` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const el of doc.querySelectorAll("[href], [src], [action], [formaction]")) {
|
|
85
|
+
for (const attr of ["href", "src", "action", "formaction"]) {
|
|
86
|
+
const value = el.getAttribute(attr);
|
|
87
|
+
if (value && /^\s*(javascript|vbscript|data:text\/html)/i.test(value.replace(/[\u0000-\u0020]/g, ""))) {
|
|
88
|
+
add({ rule: "unsafe-url", severity: "error", message: `<${el.tagName.toLowerCase()} ${attr}="${value.slice(0, 60)}"> uses a script URL; this runs code on your site.` });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const site = model.site;
|
|
93
|
+
if (site.deploy.target === "cloudflare-pages" && site.security.headers && site.security.csp === "enforce") {
|
|
94
|
+
for (const el of doc.querySelectorAll("*")) {
|
|
95
|
+
const handler = Object.keys(el.attributes).find((a) => /^on[a-z]+$/i.test(a));
|
|
96
|
+
if (handler) {
|
|
97
|
+
add({
|
|
98
|
+
rule: "csp-inline-handler",
|
|
99
|
+
severity: "warning",
|
|
100
|
+
message: `<${el.tagName.toLowerCase()} ${handler}="…"> is an inline event handler; the Content Security Policy blocks it. Use addEventListener in a script file.`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const a of doc.querySelectorAll("a[href]")) {
|
|
106
|
+
const href = a.getAttribute("href");
|
|
107
|
+
if (!a.text.trim() && !a.getAttribute("aria-label") && !a.querySelector("img[alt]:not([alt=''])")) {
|
|
108
|
+
add({ rule: "link-text", severity: "warning", message: `Link to "${href}" has no accessible text.`, docs: DOCS.linkPurpose });
|
|
109
|
+
}
|
|
110
|
+
const target = internalPath(href, page.route, model.site.site);
|
|
111
|
+
if (target === undefined)
|
|
112
|
+
continue;
|
|
113
|
+
if (target === MALFORMED) {
|
|
114
|
+
add({ rule: "link-broken", severity: "error", message: `Link to "${href}" is a malformed URL.` });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const resolved = resolveOutput(target, outputs);
|
|
118
|
+
if (resolved === "missing") {
|
|
119
|
+
const draft = drafts.has(target.endsWith("/") ? target : `${target}/`);
|
|
120
|
+
add({
|
|
121
|
+
rule: "link-broken",
|
|
122
|
+
severity: "error",
|
|
123
|
+
message: draft ? `Link to "${href}" points to a draft page, which is not published.` : `Link to "${href}" does not match any page or file.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
else if (resolved === "needs-slash") {
|
|
127
|
+
add({ rule: "link-trailing-slash", severity: "warning", message: `Link to "${href}" redirects; use "${target}/".` });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const [selector, attr] of [
|
|
131
|
+
["img[src]", "src"],
|
|
132
|
+
["script[src]", "src"],
|
|
133
|
+
["link[href]:not([rel=canonical]):not([rel=alternate])", "href"],
|
|
134
|
+
]) {
|
|
135
|
+
for (const el of doc.querySelectorAll(selector)) {
|
|
136
|
+
const value = el.getAttribute(attr);
|
|
137
|
+
const target = internalPath(value, page.route, model.site.site);
|
|
138
|
+
if (target !== undefined && (target === MALFORMED || resolveOutput(target, outputs) === "missing")) {
|
|
139
|
+
add({ rule: "asset-missing", severity: "error", message: `<${el.tagName.toLowerCase()} ${attr}="${value}"> does not exist in the build output.` });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const canonicals = doc.querySelectorAll('link[rel="canonical"]');
|
|
144
|
+
if (canonicals.length > 1) {
|
|
145
|
+
add({ rule: "canonical", severity: "error", message: `Found ${canonicals.length} canonical links; use one.`, docs: DOCS.canonical });
|
|
146
|
+
}
|
|
147
|
+
for (const link of canonicals) {
|
|
148
|
+
const href = link.getAttribute("href") ?? "";
|
|
149
|
+
if (!/^https?:\/\//.test(href)) {
|
|
150
|
+
add({ rule: "canonical", severity: "error", message: `Canonical "${href}" is not an absolute URL.`, docs: DOCS.canonical });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const target = internalPath(href, page.route, model.site.site);
|
|
154
|
+
if (target === undefined) {
|
|
155
|
+
add({ rule: "canonical", severity: "warning", message: `Canonical "${href}" points to another site; search engines may credit that site instead of yours.`, docs: DOCS.canonical });
|
|
156
|
+
}
|
|
157
|
+
else if (target === MALFORMED || resolveOutput(target, outputs) !== "ok") {
|
|
158
|
+
add({ rule: "canonical", severity: "error", message: `Canonical "${href}" does not point to a published page.`, docs: DOCS.canonical });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const script of doc.querySelectorAll('script[type="application/ld+json"]')) {
|
|
162
|
+
checkJsonLd(script.rawText, add);
|
|
163
|
+
}
|
|
164
|
+
checkAnalytics(page.html, doc, model, add);
|
|
165
|
+
}
|
|
166
|
+
function checkAnalytics(html, doc, model, add) {
|
|
167
|
+
const loaders = html.match(/googletagmanager\.com\/(gtag\/js|gtm\.js)/g) ?? [];
|
|
168
|
+
if (loaders.length > 1) {
|
|
169
|
+
add({
|
|
170
|
+
rule: "analytics-duplicate",
|
|
171
|
+
severity: "error",
|
|
172
|
+
message: `Page loads Google tags ${loaders.length} times (${loaders.join(", ")}); this double-counts. Remove the layout's tag or the integrations config.`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const { ga4, gtm, plausible } = model.site.integrations;
|
|
176
|
+
// Each provider that receives events must declare them.
|
|
177
|
+
const receivers = [
|
|
178
|
+
ga4?.measurementId && { name: "ga4", events: ga4.events },
|
|
179
|
+
gtm?.containerId && { name: "gtm", events: gtm.events },
|
|
180
|
+
plausible && { name: "plausible", events: plausible.events },
|
|
181
|
+
].filter((r) => Boolean(r));
|
|
182
|
+
for (const el of doc.querySelectorAll("[data-vage-event]")) {
|
|
183
|
+
const name = el.getAttribute("data-vage-event");
|
|
184
|
+
if (!receivers.length) {
|
|
185
|
+
add({ rule: "analytics-event", severity: "warning", message: `data-vage-event="${name}" is used but no analytics integration is configured.` });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const missing = receivers.filter((r) => !r.events[name]);
|
|
189
|
+
if (missing.length) {
|
|
190
|
+
add({
|
|
191
|
+
rule: "analytics-event",
|
|
192
|
+
severity: "error",
|
|
193
|
+
message: `Event "${name}" is not declared. Add it to ${missing.map((r) => `integrations.${r.name}.events`).join(" and ")} in vage.config.ts.`,
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const params = Object.keys(el.attributes)
|
|
198
|
+
.filter((a) => a.startsWith("data-vage-param-"))
|
|
199
|
+
.map((a) => a.slice("data-vage-param-".length).replace(/-/g, "_"));
|
|
200
|
+
for (const param of params.filter((p) => receivers.some((r) => !r.events[name].params.includes(p)))) {
|
|
201
|
+
add({ rule: "analytics-event", severity: "error", message: `Event "${name}" has undeclared parameter "${param}". Declare it in its params list.` });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function checkJsonLd(raw, add) {
|
|
206
|
+
let data;
|
|
207
|
+
try {
|
|
208
|
+
data = JSON.parse(raw);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
add({ rule: "jsonld", severity: "error", message: `JSON-LD is not valid JSON: ${err.message}`, docs: DOCS.structuredData });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const root = data;
|
|
215
|
+
if (typeof root["@context"] !== "string" || !/^https?:\/\/schema\.org\/?$/.test(root["@context"])) {
|
|
216
|
+
add({ rule: "jsonld", severity: "error", message: 'JSON-LD needs "@context": "https://schema.org".', docs: DOCS.structuredData });
|
|
217
|
+
}
|
|
218
|
+
const items = (Array.isArray(root["@graph"]) ? root["@graph"] : [root]);
|
|
219
|
+
for (const item of items) {
|
|
220
|
+
if (typeof item["@type"] !== "string") {
|
|
221
|
+
add({ rule: "jsonld", severity: "error", message: 'A JSON-LD item has no "@type".', docs: DOCS.structuredData });
|
|
222
|
+
}
|
|
223
|
+
else if (item["@type"] === "Article") {
|
|
224
|
+
const missing = ["author", "datePublished", "image"].filter((k) => item[k] === undefined);
|
|
225
|
+
if (missing.length) {
|
|
226
|
+
add({
|
|
227
|
+
rule: "jsonld-article",
|
|
228
|
+
severity: "warning",
|
|
229
|
+
message: `Article JSON-LD lacks properties Google recommends: ${missing.join(", ")}. Add them to the frontmatter only if they are true.`,
|
|
230
|
+
docs: DOCS.article,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function checkDuplicates(pages) {
|
|
237
|
+
const findings = [];
|
|
238
|
+
for (const [rule, get] of [
|
|
239
|
+
["title-duplicate", (p) => p.frontmatter.title],
|
|
240
|
+
["description-duplicate", (p) => p.frontmatter.description],
|
|
241
|
+
]) {
|
|
242
|
+
const seen = new Map();
|
|
243
|
+
for (const p of pages) {
|
|
244
|
+
const value = get(p);
|
|
245
|
+
if (value)
|
|
246
|
+
seen.set(value, [...(seen.get(value) ?? []), p.route]);
|
|
247
|
+
}
|
|
248
|
+
for (const [value, routes] of seen) {
|
|
249
|
+
if (routes.length > 1) {
|
|
250
|
+
findings.push({ rule, severity: "warning", message: `"${value}" is used by ${routes.join(", ")}. Make each page's ${rule.split("-")[0]} unique.` });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return findings;
|
|
255
|
+
}
|
|
256
|
+
function checkRobots(model) {
|
|
257
|
+
const robots = model.files.get("robots.txt");
|
|
258
|
+
if (!robots)
|
|
259
|
+
return [];
|
|
260
|
+
// Find the "*" group and look for a blanket Disallow.
|
|
261
|
+
const groups = robots.split(/\n\s*\n/);
|
|
262
|
+
const star = groups.find((g) => /^User-agent:\s*\*\s*$/im.test(g));
|
|
263
|
+
if (star && /^Disallow:\s*\/\s*$/im.test(star) && model.pages.some((p) => !p.meta.noindex)) {
|
|
264
|
+
return [
|
|
265
|
+
{
|
|
266
|
+
rule: "robots-blocks-all",
|
|
267
|
+
severity: "warning",
|
|
268
|
+
message: "robots.txt disallows all crawlers (User-agent: * / Disallow: /) while pages are indexable.",
|
|
269
|
+
source: "robots.txt",
|
|
270
|
+
docs: DOCS.robots,
|
|
271
|
+
},
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
/** Every path the build would write, as "/path" strings. */
|
|
277
|
+
function outputPaths(root, model) {
|
|
278
|
+
const out = new Set();
|
|
279
|
+
for (const p of model.pages)
|
|
280
|
+
out.add(`/${p.outFile}`);
|
|
281
|
+
if (model.notFound)
|
|
282
|
+
out.add("/404.html");
|
|
283
|
+
for (const r of model.redirects)
|
|
284
|
+
out.add(`/${r.outFile}`);
|
|
285
|
+
for (const name of model.files.keys())
|
|
286
|
+
out.add(`/${name}`);
|
|
287
|
+
const walk = (dir, prefix) => {
|
|
288
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
289
|
+
if (e.isDirectory())
|
|
290
|
+
walk(path.join(dir, e.name), `${prefix}/${e.name}`);
|
|
291
|
+
else
|
|
292
|
+
out.add(`${prefix}/${e.name}`);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const publicDir = path.join(root, "public");
|
|
296
|
+
if (fs.existsSync(publicDir))
|
|
297
|
+
walk(publicDir, "");
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
const MALFORMED = "\u0000malformed";
|
|
301
|
+
/** Returns the site-relative path for internal URLs, undefined for external ones, MALFORMED when undecodable. */
|
|
302
|
+
function internalPath(href, base, site) {
|
|
303
|
+
if (/^(mailto:|tel:|javascript:|vbscript:|data:|#)/i.test(href.trim()) || href === "")
|
|
304
|
+
return undefined;
|
|
305
|
+
let url;
|
|
306
|
+
try {
|
|
307
|
+
url = new URL(href, `http://internal.invalid${base}`);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return MALFORMED;
|
|
311
|
+
}
|
|
312
|
+
const isInternal = url.host === "internal.invalid" || (site !== undefined && url.origin === site);
|
|
313
|
+
if (!isInternal)
|
|
314
|
+
return undefined;
|
|
315
|
+
try {
|
|
316
|
+
return decodeURIComponent(url.pathname);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return MALFORMED;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function resolveOutput(target, outputs) {
|
|
323
|
+
if (target.endsWith("/"))
|
|
324
|
+
return outputs.has(`${target}index.html`) ? "ok" : "missing";
|
|
325
|
+
if (outputs.has(target))
|
|
326
|
+
return "ok";
|
|
327
|
+
if (!path.posix.extname(target) && outputs.has(`${target}/index.html`))
|
|
328
|
+
return "needs-slash";
|
|
329
|
+
return "missing";
|
|
330
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CreateResult {
|
|
2
|
+
dir: string;
|
|
3
|
+
files: string[];
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Copies the starter template into `dir`. Refuses to write into a non-empty
|
|
7
|
+
* directory. `vageSpec` is the dependency spec written to package.json
|
|
8
|
+
* (a version range, or e.g. "file:../vagejs-0.1.0.tgz" while unpublished).
|
|
9
|
+
*/
|
|
10
|
+
export declare function createProject(dir: string, vageSpec: string): CreateResult;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { VageError } from "../core/errors.js";
|
|
5
|
+
const TEMPLATE_DIR = fileURLToPath(new URL("../../templates/starter", import.meta.url));
|
|
6
|
+
/** npm strips .gitignore from published packages, so templates store it under this name. */
|
|
7
|
+
const RENAMES = { _gitignore: ".gitignore" };
|
|
8
|
+
/**
|
|
9
|
+
* Copies the starter template into `dir`. Refuses to write into a non-empty
|
|
10
|
+
* directory. `vageSpec` is the dependency spec written to package.json
|
|
11
|
+
* (a version range, or e.g. "file:../vagejs-0.1.0.tgz" while unpublished).
|
|
12
|
+
*/
|
|
13
|
+
export function createProject(dir, vageSpec) {
|
|
14
|
+
dir = path.resolve(dir);
|
|
15
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) {
|
|
16
|
+
throw new VageError(`${dir} already exists and is not empty. Choose a new directory name.`);
|
|
17
|
+
}
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
fs.cpSync(TEMPLATE_DIR, dir, { recursive: true });
|
|
20
|
+
for (const [from, to] of Object.entries(RENAMES)) {
|
|
21
|
+
if (fs.existsSync(path.join(dir, from)))
|
|
22
|
+
fs.renameSync(path.join(dir, from), path.join(dir, to));
|
|
23
|
+
}
|
|
24
|
+
const pkgPath = path.join(dir, "package.json");
|
|
25
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
26
|
+
pkg.name = toPackageName(path.basename(dir));
|
|
27
|
+
pkg.devDependencies.vagejs = vageSpec;
|
|
28
|
+
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
29
|
+
return { dir, files: listFiles(dir) };
|
|
30
|
+
}
|
|
31
|
+
function toPackageName(name) {
|
|
32
|
+
return name.toLowerCase().replace(/[^a-z0-9-~._]+/g, "-").replace(/^[-._]+/, "") || "my-website";
|
|
33
|
+
}
|
|
34
|
+
function listFiles(dir, prefix = "") {
|
|
35
|
+
return fs
|
|
36
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
37
|
+
.flatMap((e) => (e.isDirectory() ? listFiles(path.join(dir, e.name), `${prefix}${e.name}/`) : [`${prefix}${e.name}`]))
|
|
38
|
+
.sort();
|
|
39
|
+
}
|