vagejs 0.1.0 → 0.3.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/README.md +196 -25
- package/dist/checks/check.d.ts +2 -0
- package/dist/checks/check.js +106 -2
- package/dist/checks/crawlability.d.ts +34 -0
- package/dist/checks/crawlability.js +142 -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 +42 -0
- package/dist/checks/verify.js +264 -0
- package/dist/cli/index.js +118 -10
- package/dist/cli/info.d.ts +46 -0
- package/dist/cli/info.js +65 -5
- package/dist/content/images.d.ts +26 -0
- package/dist/content/images.js +129 -0
- package/dist/content/markdown.d.ts +4 -0
- package/dist/content/markdown.js +8 -1
- package/dist/content/optimize.d.ts +31 -0
- package/dist/content/optimize.js +92 -0
- package/dist/content/sanitize.d.ts +18 -2
- package/dist/content/sanitize.js +49 -9
- package/dist/core/build.js +11 -0
- package/dist/core/clone.d.ts +6 -0
- package/dist/core/clone.js +16 -0
- package/dist/core/config.d.ts +26 -0
- package/dist/core/config.js +43 -4
- package/dist/core/dates.d.ts +2 -0
- package/dist/core/dates.js +4 -0
- package/dist/core/errors.d.ts +2 -0
- package/dist/core/errors.js +3 -0
- package/dist/core/html.d.ts +22 -0
- package/dist/core/html.js +31 -0
- package/dist/core/pages.d.ts +67 -0
- package/dist/core/pages.js +168 -0
- package/dist/core/render.d.ts +24 -1
- package/dist/core/render.js +69 -10
- package/dist/core/site.d.ts +12 -1
- package/dist/core/site.js +77 -18
- package/dist/core/validate.d.ts +2 -0
- package/dist/core/validate.js +6 -0
- package/dist/deploy/cloudflare-pages.js +3 -1
- package/dist/deploy/cloudflare-workers.d.ts +59 -0
- package/dist/deploy/cloudflare-workers.js +223 -0
- package/dist/deploy/github-pages.js +2 -0
- package/dist/deploy/headers.js +1 -1
- package/dist/deploy/publish.d.ts +1 -1
- package/dist/deploy/publish.js +3 -3
- package/dist/deploy/redirects.js +1 -1
- package/dist/deploy/secrets.js +1 -1
- package/dist/dev/guard.d.ts +2 -0
- package/dist/dev/guard.js +30 -1
- package/dist/dev/server.js +22 -4
- package/dist/discovery/feed.d.ts +21 -0
- package/dist/discovery/feed.js +68 -0
- package/dist/discovery/git-dates.d.ts +10 -0
- package/dist/discovery/git-dates.js +57 -0
- package/dist/discovery/markdown-copy.d.ts +13 -0
- package/dist/discovery/markdown-copy.js +112 -0
- package/dist/guidelines/rule-pack.json +101 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/integrations/api-keys.d.ts +1 -1
- package/dist/integrations/api-keys.js +1 -1
- package/dist/integrations/google-auth.js +1 -1
- package/dist/integrations/tags.d.ts +1 -1
- package/dist/integrations/tags.js +1 -1
- package/dist/seo/head.js +17 -1
- package/package.json +4 -1
- package/templates/starter/.github/dependabot.yml +11 -0
- package/templates/starter/.github/workflows/ci.yml +4 -0
- package/templates/starter/.github/workflows/verify.yml +34 -0
- package/templates/starter/AGENTS.md +45 -16
- package/templates/starter/links.lock.json +4 -0
- package/templates/starter/package.json +1 -1
- package/templates/starter/src/layouts/blog.ts +13 -0
- package/templates/starter/src/layouts/default.ts +8 -7
- package/templates/starter/src/pages/blog/index.md +7 -0
- package/templates/starter/src/pages/index.md +2 -2
- package/templates/starter/vage.config.ts +5 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type HTMLElement } from "node-html-parser";
|
|
2
|
+
import type { SiteModel } from "../core/site.js";
|
|
3
|
+
/**
|
|
4
|
+
* Defenses against injected spam (the classic hacked-site problem): links hidden with
|
|
5
|
+
* CSS, outbound links nobody approved, and common spam vocabulary. These run in
|
|
6
|
+
* `vage check` on the final HTML, so they cover page content and layouts alike.
|
|
7
|
+
*/
|
|
8
|
+
export declare const LINK_LOCK = "links.lock.json";
|
|
9
|
+
export interface Hidden {
|
|
10
|
+
reason: string;
|
|
11
|
+
/** "strong": a CSS/SVG trick with no legitimate use around a link. "soft": hidden by a mechanism sites also use for menus (hidden, closed <details>). */
|
|
12
|
+
strength: "strong" | "soft";
|
|
13
|
+
}
|
|
14
|
+
/** Why this element (or an ancestor) is hidden from people, if it is. Only inline signals; stylesheets aren't evaluated. */
|
|
15
|
+
export declare function hiddenReason(el: HTMLElement): Hidden | undefined;
|
|
16
|
+
/** A link with nothing a visitor can see or a screen reader can announce. */
|
|
17
|
+
export declare function isEmptyLink(a: HTMLElement): boolean;
|
|
18
|
+
/** href of <a>/<area>, including SVG's xlink:href. */
|
|
19
|
+
export declare function linkTarget(a: HTMLElement): string | undefined;
|
|
20
|
+
/** Normalized host of an external http(s) link ("www." dropped), or undefined for internal/other links. */
|
|
21
|
+
export declare function externalHost(href: string, site: string | undefined): string | undefined;
|
|
22
|
+
/** Lowercase, punycode, no "www.", no trailing dot: one spelling per host. */
|
|
23
|
+
export declare function normalizeHost(host: string): string;
|
|
24
|
+
/** Every external host the built site links to, with the pages that link there. */
|
|
25
|
+
export declare function outboundHosts(model: SiteModel): Map<string, {
|
|
26
|
+
route: string;
|
|
27
|
+
source: string;
|
|
28
|
+
}[]>;
|
|
29
|
+
export interface LinkLock {
|
|
30
|
+
domains: string[];
|
|
31
|
+
}
|
|
32
|
+
export declare function readLinkLock(root: string): LinkLock | undefined;
|
|
33
|
+
export declare function writeLinkLock(root: string, domains: Iterable<string>): void;
|
|
34
|
+
/** Words that rarely appear on legitimate business sites but are staples of injected spam. Warnings only. */
|
|
35
|
+
export declare const SPAM_PATTERNS: [RegExp, string][];
|
|
36
|
+
export declare function spamHits(text: string): string[];
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "node-html-parser";
|
|
4
|
+
import { VageError } from "../core/errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* Defenses against injected spam (the classic hacked-site problem): links hidden with
|
|
7
|
+
* CSS, outbound links nobody approved, and common spam vocabulary. These run in
|
|
8
|
+
* `vage check` on the final HTML, so they cover page content and layouts alike.
|
|
9
|
+
*/
|
|
10
|
+
export const LINK_LOCK = "links.lock.json";
|
|
11
|
+
// ---------------------------------------------------------------- hidden links
|
|
12
|
+
const HIDING_STYLES = [
|
|
13
|
+
[/display\s*:\s*none/i, "display:none"],
|
|
14
|
+
[/visibility\s*:\s*(hidden|collapse)/i, "visibility:hidden"],
|
|
15
|
+
[/opacity\s*:\s*0(\.0+)?\s*(;|$|!)/i, "opacity:0"],
|
|
16
|
+
[/font-size\s*:\s*0(\.0+)?(px|em|rem|%)?\s*(;|$|!)/i, "font-size:0"],
|
|
17
|
+
[/(^|;|\s)(width|height|max-width|max-height)\s*:\s*0(px)?\s*(;|$|!)/i, "zero size"],
|
|
18
|
+
[/(^|;|\s)(left|top|right|bottom|margin-left|margin-top)\s*:\s*-\s*\d{3,}/i, "positioned off-screen"],
|
|
19
|
+
[/text-indent\s*:\s*-\s*\d{3,}/i, "text pushed off-screen"],
|
|
20
|
+
[/clip\s*:\s*rect\(\s*0/i, "clipped away"],
|
|
21
|
+
[/clip-path\s*:\s*inset\(\s*(50%|100%)/i, "clipped away"],
|
|
22
|
+
];
|
|
23
|
+
/** Why this element (or an ancestor) is hidden from people, if it is. Only inline signals; stylesheets aren't evaluated. */
|
|
24
|
+
export function hiddenReason(el) {
|
|
25
|
+
for (let node = el; node && node.tagName; node = node.parentNode) {
|
|
26
|
+
const tag = node.tagName.toLowerCase();
|
|
27
|
+
const where = node === el ? "" : "inside ";
|
|
28
|
+
const style = node.getAttribute?.("style");
|
|
29
|
+
if (style) {
|
|
30
|
+
const hit = HIDING_STYLES.find(([re]) => re.test(style));
|
|
31
|
+
if (hit)
|
|
32
|
+
return { reason: `${where ? "inside an element " : ""}hidden with ${hit[1]}`, strength: "strong" };
|
|
33
|
+
}
|
|
34
|
+
if (node.getAttribute?.("aria-hidden") === "true")
|
|
35
|
+
return { reason: `${where}aria-hidden="true"`, strength: "strong" };
|
|
36
|
+
if (tag === "svg" && (/^0(px)?$/.test(node.getAttribute("width") ?? "") || /^0(px)?$/.test(node.getAttribute("height") ?? ""))) {
|
|
37
|
+
return { reason: "inside a zero-size <svg>", strength: "strong" };
|
|
38
|
+
}
|
|
39
|
+
if (node.hasAttribute?.("hidden"))
|
|
40
|
+
return { reason: `inside a hidden <${tag}>`, strength: "soft" };
|
|
41
|
+
if (tag === "details" && node !== el && !node.hasAttribute("open") && !el.closest?.("summary")) {
|
|
42
|
+
return { reason: "inside a closed <details>", strength: "soft" };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
/** A link with nothing a visitor can see or a screen reader can announce. */
|
|
48
|
+
export function isEmptyLink(a) {
|
|
49
|
+
return !a.text.trim() && !a.getAttribute("aria-label") && !a.querySelector("img[alt]:not([alt=''])");
|
|
50
|
+
}
|
|
51
|
+
/** href of <a>/<area>, including SVG's xlink:href. */
|
|
52
|
+
export function linkTarget(a) {
|
|
53
|
+
return a.getAttribute("href") ?? a.getAttribute("xlink:href") ?? undefined;
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------- outbound links
|
|
56
|
+
/** Normalized host of an external http(s) link ("www." dropped), or undefined for internal/other links. */
|
|
57
|
+
export function externalHost(href, site) {
|
|
58
|
+
let url;
|
|
59
|
+
try {
|
|
60
|
+
url = new URL(href.trim(), site ?? "https://site.invalid");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
66
|
+
return undefined;
|
|
67
|
+
const host = normalizeHost(url.hostname);
|
|
68
|
+
const own = site ? normalizeHost(new URL(site).hostname) : "site.invalid";
|
|
69
|
+
return host === own ? undefined : host;
|
|
70
|
+
}
|
|
71
|
+
/** Lowercase, punycode, no "www.", no trailing dot: one spelling per host. */
|
|
72
|
+
export function normalizeHost(host) {
|
|
73
|
+
return host.toLowerCase().replace(/\.$/, "").replace(/^www\./, "");
|
|
74
|
+
}
|
|
75
|
+
/** Places in a page that send visitors or search engines elsewhere, beyond <a>: all go through the link lock. */
|
|
76
|
+
const OTHER_TARGETS = [
|
|
77
|
+
['link[rel="canonical"]', (el) => el.getAttribute("href")],
|
|
78
|
+
['meta[property="og:url"]', (el) => el.getAttribute("content")],
|
|
79
|
+
["meta[http-equiv]", (el) => (/^refresh$/i.test(el.getAttribute("http-equiv") ?? "") ? /url\s*=\s*['"]?([^'";]+)/i.exec(el.getAttribute("content") ?? "")?.[1] : undefined)],
|
|
80
|
+
["iframe[src]", (el) => el.getAttribute("src")],
|
|
81
|
+
["form[action]", (el) => el.getAttribute("action")],
|
|
82
|
+
];
|
|
83
|
+
/** Every external host the built site links to, with the pages that link there. */
|
|
84
|
+
export function outboundHosts(model) {
|
|
85
|
+
const hosts = new Map();
|
|
86
|
+
const pages = model.notFound ? [...model.pages, model.notFound] : model.pages;
|
|
87
|
+
for (const page of pages) {
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
const doc = parse(page.html);
|
|
90
|
+
const targets = [
|
|
91
|
+
...doc.querySelectorAll("a, area").map(linkTarget),
|
|
92
|
+
...OTHER_TARGETS.flatMap(([selector, read]) => doc.querySelectorAll(selector).map(read)),
|
|
93
|
+
];
|
|
94
|
+
for (const target of targets) {
|
|
95
|
+
if (!target)
|
|
96
|
+
continue;
|
|
97
|
+
const host = externalHost(target, model.site.site);
|
|
98
|
+
if (!host || seen.has(host))
|
|
99
|
+
continue;
|
|
100
|
+
seen.add(host);
|
|
101
|
+
hosts.set(host, [...(hosts.get(host) ?? []), { route: page.route, source: page.source }]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return new Map([...hosts].sort(([a], [b]) => a.localeCompare(b)));
|
|
105
|
+
}
|
|
106
|
+
export function readLinkLock(root) {
|
|
107
|
+
const file = path.join(root, LINK_LOCK);
|
|
108
|
+
if (!fs.existsSync(file))
|
|
109
|
+
return undefined;
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
throw new VageError(`${LINK_LOCK} is not valid JSON: ${err.message}`, file);
|
|
116
|
+
}
|
|
117
|
+
const domains = data?.domains;
|
|
118
|
+
if (!Array.isArray(domains) || !domains.every((d) => typeof d === "string")) {
|
|
119
|
+
throw new VageError(`${LINK_LOCK} must look like { "domains": ["example.com"] }.`, file);
|
|
120
|
+
}
|
|
121
|
+
return { domains: domains.map(normalizeHost) };
|
|
122
|
+
}
|
|
123
|
+
export function writeLinkLock(root, domains) {
|
|
124
|
+
const sorted = [...new Set(domains)].sort();
|
|
125
|
+
const body = {
|
|
126
|
+
$comment: "External domains this site may link to. vage check fails on links to any other domain. Only the site owner approves new ones: npx vage links approve <domain>",
|
|
127
|
+
domains: sorted,
|
|
128
|
+
};
|
|
129
|
+
fs.writeFileSync(path.join(root, LINK_LOCK), `${JSON.stringify(body, null, 2)}\n`);
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------- spam vocabulary
|
|
132
|
+
/** Words that rarely appear on legitimate business sites but are staples of injected spam. Warnings only. */
|
|
133
|
+
export const SPAM_PATTERNS = [
|
|
134
|
+
[/\b(online|best|free|top)\s+casinos?\b|\bcasino\s+(bonus|online|games?|sites?)\b/i, "casino"],
|
|
135
|
+
[/\b(viagra|cialis|levitra|kamagra)\b/i, "pharmacy spam"],
|
|
136
|
+
[/\bpayday\s+loans?\b/i, "payday loans"],
|
|
137
|
+
[/\breplica\s+(watches|handbags|bags|rolex)\b/i, "replica goods"],
|
|
138
|
+
[/\bescort\s+(girls|agency|agencies)\b/i, "escort ads"],
|
|
139
|
+
[/\bporn\b|\bxxx\s+(videos?|porn|sex)\b/i, "adult content"],
|
|
140
|
+
[/\bbuy\s+(cheap\s+)?(followers|backlinks|likes)\b/i, "bought followers or backlinks"],
|
|
141
|
+
[/\bonline\s+slots\b|\bslot\s+gacor\b|\bjudi\s+online\b/i, "online gambling"],
|
|
142
|
+
];
|
|
143
|
+
export function spamHits(text) {
|
|
144
|
+
return [...new Set(SPAM_PATTERNS.filter(([re]) => re.test(text)).map(([, label]) => label))];
|
|
145
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readLinkLock, writeLinkLock } from "./integrity.js";
|
|
2
|
+
export interface LinkStatus {
|
|
3
|
+
/** External domains the site links to now, with how many pages link there, and whether they're approved. */
|
|
4
|
+
domains: {
|
|
5
|
+
domain: string;
|
|
6
|
+
pages: string[];
|
|
7
|
+
approved: boolean;
|
|
8
|
+
}[];
|
|
9
|
+
/** Approved domains the site no longer links to. */
|
|
10
|
+
unused: string[];
|
|
11
|
+
hasLock: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function linkStatus(root: string): Promise<LinkStatus>;
|
|
14
|
+
/** "Bücher.DE." -> "xn--bcher-kva.de"; throws for anything that isn't a bare host (or IP). */
|
|
15
|
+
export declare function parseHost(input: string): string;
|
|
16
|
+
/** The lock file's domains after approving `add` (and, with `prune`, dropping unused ones). */
|
|
17
|
+
export declare function nextLock(status: LinkStatus, current: string[], add: string[], prune: boolean): string[];
|
|
18
|
+
export { readLinkLock, writeLinkLock };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { VageError } from "../core/errors.js";
|
|
3
|
+
import { createBuildLoader } from "../core/loader.js";
|
|
4
|
+
import { renderSite } from "../core/site.js";
|
|
5
|
+
import { normalizeHost, outboundHosts, readLinkLock, writeLinkLock } from "./integrity.js";
|
|
6
|
+
export async function linkStatus(root) {
|
|
7
|
+
root = path.resolve(root);
|
|
8
|
+
const loader = await createBuildLoader(root);
|
|
9
|
+
try {
|
|
10
|
+
const model = await renderSite(root, loader.load);
|
|
11
|
+
const hosts = outboundHosts(model);
|
|
12
|
+
const lock = readLinkLock(root);
|
|
13
|
+
const approved = new Set(lock?.domains ?? []);
|
|
14
|
+
return {
|
|
15
|
+
domains: [...hosts].map(([domain, pages]) => ({ domain, pages: pages.map((p) => p.route), approved: approved.has(domain) })),
|
|
16
|
+
unused: [...approved].filter((d) => !hosts.has(d)).sort(),
|
|
17
|
+
hasLock: lock !== undefined,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await loader.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** "Bücher.DE." -> "xn--bcher-kva.de"; throws for anything that isn't a bare host (or IP). */
|
|
25
|
+
export function parseHost(input) {
|
|
26
|
+
const raw = input.trim();
|
|
27
|
+
let host;
|
|
28
|
+
try {
|
|
29
|
+
if (/[/\\?#@\s]/.test(raw) || raw.includes("://"))
|
|
30
|
+
throw new Error("not a host");
|
|
31
|
+
host = new URL(`http://${raw}`).hostname;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new VageError(`"${input}" is not a domain name. Use a host like example.com (no https://, no path).`);
|
|
35
|
+
}
|
|
36
|
+
host = normalizeHost(host);
|
|
37
|
+
if (!host || (!host.includes(".") && !host.startsWith("[")))
|
|
38
|
+
throw new VageError(`"${input}" is not a domain name. Use a host like example.com.`);
|
|
39
|
+
return host;
|
|
40
|
+
}
|
|
41
|
+
/** The lock file's domains after approving `add` (and, with `prune`, dropping unused ones). */
|
|
42
|
+
export function nextLock(status, current, add, prune) {
|
|
43
|
+
add = add.map(parseHost);
|
|
44
|
+
const keep = prune ? current.filter((d) => !status.unused.includes(d)) : current;
|
|
45
|
+
return [...new Set([...keep, ...add])].sort();
|
|
46
|
+
}
|
|
47
|
+
export { readLinkLock, writeLinkLock };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vage verify`: compares the live site with a fresh local render of the project. Anything
|
|
3
|
+
* on the live site that isn't in your files (extra links, extra scripts, changed text, extra
|
|
4
|
+
* pages in sitemap.xml) means the site was changed outside your repository: a hacked host
|
|
5
|
+
* account, a compromised deploy, or a CDN injecting content.
|
|
6
|
+
*/
|
|
7
|
+
export interface PageFingerprint {
|
|
8
|
+
/** Where the page sends people and crawlers: links, canonical/og:url, meta refresh, iframes, embeds, forms. */
|
|
9
|
+
links: string[];
|
|
10
|
+
/** Scripts (src or hash), inline event handlers (hash) and JSON-LD blocks (hash). */
|
|
11
|
+
scripts: string[];
|
|
12
|
+
/** Scripts a CDN injected (Cloudflare email protection, Web Analytics): not content, but they can break your security policy. */
|
|
13
|
+
cdnScripts: string[];
|
|
14
|
+
text: string;
|
|
15
|
+
/** Spam vocabulary in the visible text (read with spaces intact). */
|
|
16
|
+
spam: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface Difference {
|
|
19
|
+
route: string;
|
|
20
|
+
severity: "error" | "warning";
|
|
21
|
+
kind: "unreachable" | "links-added" | "links-removed" | "scripts-added" | "text-changed" | "sitemap-extra" | "robots-changed" | "cdn-script" | "crawler-blocked" | "robots-blocked" | "noindex-header" | "redirect-missing" | "duplicate-host";
|
|
22
|
+
detail: string;
|
|
23
|
+
}
|
|
24
|
+
export interface VerifyResult {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
url: string;
|
|
27
|
+
pagesChecked: number;
|
|
28
|
+
/** Crawlers probed by the crawlability check (0 when skipped or when the site blocks everyone). */
|
|
29
|
+
crawlersChecked: number;
|
|
30
|
+
differences: Difference[];
|
|
31
|
+
}
|
|
32
|
+
/** Every link-like target in a text file (Markdown, RSS, llms.txt), normalized for comparison. */
|
|
33
|
+
export declare function textLinks(text: string): Set<string>;
|
|
34
|
+
/** What matters for injected spam: where the page sends people, what runs on it, and its visible text, independent of formatting. */
|
|
35
|
+
export declare function fingerprint(html: string, pageUrl: string): PageFingerprint;
|
|
36
|
+
export declare function compare(route: string, local: PageFingerprint, live: PageFingerprint): Difference[];
|
|
37
|
+
export declare function verifySite(root: string, options?: {
|
|
38
|
+
url?: string;
|
|
39
|
+
fetch?: typeof fetch;
|
|
40
|
+
concurrency?: number;
|
|
41
|
+
crawlers?: boolean;
|
|
42
|
+
}): Promise<VerifyResult>;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "node-html-parser";
|
|
4
|
+
import { VageError } from "../core/errors.js";
|
|
5
|
+
import { createBuildLoader } from "../core/loader.js";
|
|
6
|
+
import { renderSite } from "../core/site.js";
|
|
7
|
+
import { spamHits } from "./integrity.js";
|
|
8
|
+
const GOOGLEBOT = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
|
|
9
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
10
|
+
/**
|
|
11
|
+
* Scripts Cloudflare adds by itself, matched by their exact shape so an injected script
|
|
12
|
+
* that merely mentions /cdn-cgi/ is still reported as an added script.
|
|
13
|
+
*/
|
|
14
|
+
const BEACON = /^https:\/\/static\.cloudflareinsights\.com\/beacon\.min\.js(\/[\w.]*)?$/;
|
|
15
|
+
const CDN_PATH = /^\/cdn-cgi\/(scripts\/[0-9a-f]+\/cloudflare-static\/email-decode\.min\.js|challenge-platform\/(h\/[a-z]\/)?scripts\/jsd\/main\.js)$/;
|
|
16
|
+
/** Cloudflare's scripts: the Web Analytics beacon, or a /cdn-cgi/ script on the site's own host (never another site's). */
|
|
17
|
+
const isCdnScript = (url, pageHost) => {
|
|
18
|
+
if (BEACON.test(url))
|
|
19
|
+
return true;
|
|
20
|
+
try {
|
|
21
|
+
const u = new URL(url);
|
|
22
|
+
return u.host === pageHost && !u.search && CDN_PATH.test(u.pathname);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/** Cloudflare's JS-detection loader: sets window.__CF$cv$params and loads /cdn-cgi/challenge-platform/.../main.js, nothing else. */
|
|
29
|
+
const CDN_INLINE = /^\s*\(function\(\)\{function c\(\)\{var b=a\.contentDocument\|\|a\.contentWindow\.document;if\(b\)\{var d=b\.createElement\('script'\);d\.innerHTML="window\.__CF\$cv\$params=\{r:'[0-9a-f]+',t:'[A-Za-z0-9+\/=]+'\};var a=document\.createElement\('script'\);a\.src='\/cdn-cgi\/challenge-platform\/scripts\/jsd\/main\.js';document\.getElementsByTagName\('head'\)\[0\]\.appendChild\(a\);";b\.getElementsByTagName\('head'\)\[0\]\.appendChild\(d\)\}\}if\(document\.body\)\{var a=document\.createElement\('iframe'\);a\.height=1;a\.width=1;a\.style\.position='absolute';a\.style\.top=0;a\.style\.left=0;a\.style\.border='none';a\.style\.visibility='hidden';document\.body\.appendChild\(a\);if\('loading'!==document\.readyState\)c\(\);else if\(window\.addEventListener\)document\.addEventListener\('DOMContentLoaded',c\);else\{var e=document\.onreadystatechange\|\|function\(\)\{\};document\.onreadystatechange=function\(b\)\{e\(b\);'loading'!==document\.readyState&&\(document\.onreadystatechange=e,c\(\)\)\}\}\}\}\)\(\);\s*$/;
|
|
30
|
+
/** Cloudflare email protection rewrites mailto: links to this (on the site's own host); not a content change. */
|
|
31
|
+
const isCdnLink = (url, pageHost) => {
|
|
32
|
+
try {
|
|
33
|
+
const u = new URL(url);
|
|
34
|
+
return u.host === pageHost && u.pathname === "/cdn-cgi/l/email-protection" && !u.search && /^(#[0-9a-f]*)?$/.test(u.hash);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** Every link-like target in a text file (Markdown, RSS, llms.txt), normalized for comparison. */
|
|
41
|
+
export function textLinks(text) {
|
|
42
|
+
const found = new Set();
|
|
43
|
+
const add = (raw) => found.add(raw.trim().replace(/[.,;:!?]+$/, "").toLowerCase());
|
|
44
|
+
for (const m of text.matchAll(/\]\(\s*<?([^)\s>]+)/g))
|
|
45
|
+
add(m[1]); // [text](target)
|
|
46
|
+
for (const m of text.matchAll(/^\s{0,3}\[[^\]]+\]:\s*<?([^\s>]+)/gm))
|
|
47
|
+
add(m[1]); // [ref]: target
|
|
48
|
+
for (const m of text.matchAll(/<((?:[a-z][a-z0-9+.-]*:|\/\/)[^>\s]+)>/gi))
|
|
49
|
+
add(m[1]); // <autolink>
|
|
50
|
+
for (const m of text.matchAll(/(?:\b[a-z][a-z0-9+.-]*:)?\/\/[^\s)<>"'\]]+|\bwww\.[^\s)<>"'\]]+/gi))
|
|
51
|
+
add(m[0]); // bare URLs, //host, www.
|
|
52
|
+
for (const m of text.matchAll(/<link>([^<]+)<\/link>|<guid[^>]*>([^<]+)<\/guid>/gi))
|
|
53
|
+
add((m[1] ?? m[2])); // RSS
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
const TARGETS = [
|
|
57
|
+
["a, area", "", (el) => el.getAttribute("href") ?? el.getAttribute("xlink:href")],
|
|
58
|
+
["link[href]", "link:", (el) => el.getAttribute("href")],
|
|
59
|
+
['meta[property="og:url"]', "og:url:", (el) => el.getAttribute("content")],
|
|
60
|
+
["meta[http-equiv]", "refresh:", (el) => (/^refresh$/i.test(el.getAttribute("http-equiv") ?? "") ? /url\s*=\s*['"]?([^'";]+)/i.exec(el.getAttribute("content") ?? "")?.[1] : undefined)],
|
|
61
|
+
["iframe[src], embed[src], frame[src]", "frame:", (el) => el.getAttribute("src")],
|
|
62
|
+
["object[data]", "object:", (el) => el.getAttribute("data")],
|
|
63
|
+
["form[action]", "form:", (el) => el.getAttribute("action")],
|
|
64
|
+
];
|
|
65
|
+
/** What matters for injected spam: where the page sends people, what runs on it, and its visible text, independent of formatting. */
|
|
66
|
+
export function fingerprint(html, pageUrl) {
|
|
67
|
+
const doc = parse(html);
|
|
68
|
+
const pageHost = new URL(pageUrl).host;
|
|
69
|
+
const abs = (href) => {
|
|
70
|
+
try {
|
|
71
|
+
return new URL(href.trim(), pageUrl).href;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return href.trim();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const links = new Set();
|
|
78
|
+
for (const [selector, prefix, read] of TARGETS) {
|
|
79
|
+
for (const el of doc.querySelectorAll(selector)) {
|
|
80
|
+
const value = read(el);
|
|
81
|
+
if (!value || value.trim().startsWith("mailto:"))
|
|
82
|
+
continue;
|
|
83
|
+
const url = abs(value);
|
|
84
|
+
if (isCdnLink(url, pageHost))
|
|
85
|
+
continue;
|
|
86
|
+
links.add(prefix + url);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const scripts = new Set();
|
|
90
|
+
const cdnScripts = new Set();
|
|
91
|
+
for (const el of doc.querySelectorAll("script")) {
|
|
92
|
+
const src = el.getAttribute("src");
|
|
93
|
+
if (src) {
|
|
94
|
+
const url = abs(src);
|
|
95
|
+
if (isCdnScript(url, pageHost))
|
|
96
|
+
cdnScripts.add(url.replace(/\?.*$/, ""));
|
|
97
|
+
else
|
|
98
|
+
scripts.add(`src:${url}`);
|
|
99
|
+
}
|
|
100
|
+
else if (el.getAttribute("type") === "application/ld+json")
|
|
101
|
+
scripts.add(`json-ld:${sha(el.text.replace(/\s+/g, ""))}`);
|
|
102
|
+
else if (CDN_INLINE.test(el.text))
|
|
103
|
+
cdnScripts.add("Cloudflare's inline bot-detection script (/cdn-cgi/challenge-platform/)");
|
|
104
|
+
else
|
|
105
|
+
scripts.add(`inline:${sha(el.text.trim())}`);
|
|
106
|
+
}
|
|
107
|
+
for (const el of doc.querySelectorAll("*")) {
|
|
108
|
+
for (const [name, value] of Object.entries(el.attributes)) {
|
|
109
|
+
if (/^on/i.test(name))
|
|
110
|
+
scripts.add(`handler:${el.tagName.toLowerCase()}.${name.toLowerCase()}:${sha(value)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const el of doc.querySelectorAll("script, style, noscript, template"))
|
|
114
|
+
el.remove();
|
|
115
|
+
// Whitespace between elements depends on formatting, not content, so it's ignored entirely.
|
|
116
|
+
const visible = (doc.querySelector("body") ?? doc).text;
|
|
117
|
+
return { links: [...links].sort(), scripts: [...scripts].sort(), cdnScripts: [...cdnScripts].sort(), text: visible.replace(/\s+/g, ""), spam: spamHits(visible.replace(/\s+/g, " ")) };
|
|
118
|
+
}
|
|
119
|
+
export function compare(route, local, live) {
|
|
120
|
+
const out = [];
|
|
121
|
+
const added = live.links.filter((l) => !local.links.includes(l));
|
|
122
|
+
const removed = local.links.filter((l) => !live.links.includes(l));
|
|
123
|
+
const scripts = live.scripts.filter((s) => !local.scripts.includes(s));
|
|
124
|
+
if (added.length)
|
|
125
|
+
out.push({ route, severity: "error", kind: "links-added", detail: `Links on the live page that aren't in your files: ${added.slice(0, 5).join(", ")}${added.length > 5 ? ` (+${added.length - 5})` : ""}` });
|
|
126
|
+
if (scripts.length)
|
|
127
|
+
out.push({ route, severity: "error", kind: "scripts-added", detail: `Scripts on the live page that aren't in your files: ${scripts.slice(0, 5).join(", ")}` });
|
|
128
|
+
if (removed.length)
|
|
129
|
+
out.push({ route, severity: "warning", kind: "links-removed", detail: `Links missing from the live page: ${removed.slice(0, 5).join(", ")}${removed.length > 5 ? ` (+${removed.length - 5})` : ""}` });
|
|
130
|
+
if (local.text !== live.text) {
|
|
131
|
+
const spam = live.spam.filter((h) => !local.spam.includes(h));
|
|
132
|
+
out.push(spam.length
|
|
133
|
+
? { route, severity: "error", kind: "text-changed", detail: `The live page's text differs from your files and contains spam words (${spam.join(", ")}).` }
|
|
134
|
+
: { route, severity: "warning", kind: "text-changed", detail: "The live page's text differs from your files (an old deploy, or content changed outside the repository)." });
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function sitemapUrls(xml) {
|
|
139
|
+
return [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((m) => m[1].replace(/&/g, "&"));
|
|
140
|
+
}
|
|
141
|
+
/** Browser-like request headers: some CDNs only inject scripts for browsers, and those are what visitors get. */
|
|
142
|
+
const BROWSER = "Mozilla/5.0 (compatible; vagejs-verify) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
143
|
+
export async function verifySite(root, options = {}) {
|
|
144
|
+
root = path.resolve(root);
|
|
145
|
+
const doFetch = options.fetch ?? fetch;
|
|
146
|
+
const loader = await createBuildLoader(root);
|
|
147
|
+
try {
|
|
148
|
+
const model = await renderSite(root, loader.load);
|
|
149
|
+
const origin = (options.url ?? model.site.site)?.replace(/\/+$/, "");
|
|
150
|
+
if (!origin || !/^https?:\/\/[^/]+$/.test(origin))
|
|
151
|
+
throw new VageError('Pass the live site\'s origin with --url https://example.com, or set "site" in vage.config.ts.');
|
|
152
|
+
const get = async (url, userAgent = BROWSER) => {
|
|
153
|
+
try {
|
|
154
|
+
const res = await doFetch(url, { redirect: "follow", headers: { "user-agent": userAgent, accept: "text/html,application/xhtml+xml,*/*;q=0.8" }, signal: AbortSignal.timeout(20_000) });
|
|
155
|
+
return { status: res.status, body: res.ok ? await res.text() : "" };
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
return { status: 0, body: "", error: err.message };
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const differences = [];
|
|
162
|
+
const cdnSeen = new Set();
|
|
163
|
+
const queue = [...model.pages];
|
|
164
|
+
const worker = async () => {
|
|
165
|
+
for (let page = queue.shift(); page; page = queue.shift()) {
|
|
166
|
+
const url = `${origin}${page.route}`;
|
|
167
|
+
const live = await get(url);
|
|
168
|
+
if (live.status !== 200) {
|
|
169
|
+
differences.push({ route: page.route, severity: "error", kind: "unreachable", detail: live.status ? `HTTP ${live.status}` : "no response" });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const local = fingerprint(page.html, url);
|
|
173
|
+
const livePrint = fingerprint(live.body, url);
|
|
174
|
+
const seen = compare(page.route, local, livePrint);
|
|
175
|
+
differences.push(...seen);
|
|
176
|
+
for (const src of livePrint.cdnScripts)
|
|
177
|
+
cdnSeen.add(src);
|
|
178
|
+
// Hacked sites often show spam only to search engines ("cloaking"): look again as Googlebot.
|
|
179
|
+
// Skipped when Googlebot gets a challenge page (the crawlability check reports that once).
|
|
180
|
+
const bot = await get(url, GOOGLEBOT);
|
|
181
|
+
if (bot.status === 200 && !/<title>\s*(Just a moment|Attention Required|Access denied|Checking your browser)/i.test(bot.body)) {
|
|
182
|
+
for (const d of compare(page.route, local, fingerprint(bot.body, url))) {
|
|
183
|
+
if (!seen.some((x) => x.kind === d.kind && x.detail === d.detail))
|
|
184
|
+
differences.push({ ...d, detail: `Only when visited as Googlebot: ${d.detail}` });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
await Promise.all(Array.from({ length: Math.max(1, options.concurrency ?? 4) }, worker));
|
|
190
|
+
// A planted Sitemap: line or new rules in robots.txt point crawlers at spam.
|
|
191
|
+
const localRobots = model.files.get("robots.txt");
|
|
192
|
+
if (localRobots) {
|
|
193
|
+
const live = await get(`${origin}/robots.txt`);
|
|
194
|
+
const lines = (t) => t.split(/\r?\n/).map((l) => l.replace(/#.*/, "").trim()).filter(Boolean).join("\n");
|
|
195
|
+
if (live.status === 200 && lines(live.body) !== lines(localRobots)) {
|
|
196
|
+
differences.push({ route: "/robots.txt", severity: "error", kind: "robots-changed", detail: "The live robots.txt differs from the one your build writes." });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Pages someone added on the server announce themselves in the live sitemap.
|
|
200
|
+
const localSitemap = model.files.get("sitemap.xml");
|
|
201
|
+
if (localSitemap) {
|
|
202
|
+
const live = await get(`${origin}/sitemap.xml`);
|
|
203
|
+
if (live.status === 200) {
|
|
204
|
+
const known = new Set(sitemapUrls(localSitemap).map((u) => new URL(u).pathname));
|
|
205
|
+
const extra = sitemapUrls(live.body).filter((u) => {
|
|
206
|
+
try {
|
|
207
|
+
return !known.has(new URL(u).pathname);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
if (extra.length)
|
|
214
|
+
differences.push({ route: "/sitemap.xml", severity: "error", kind: "sitemap-extra", detail: `The live sitemap lists pages that aren't in your files: ${extra.slice(0, 5).join(", ")}${extra.length > 5 ? ` (+${extra.length - 5})` : ""}` });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const src of cdnSeen) {
|
|
218
|
+
differences.push({
|
|
219
|
+
route: "/",
|
|
220
|
+
severity: "warning",
|
|
221
|
+
kind: "cdn-script",
|
|
222
|
+
detail: `Your host or CDN adds ${src} to pages. It isn't in your files, and your security policy may block it (browser console errors). Turn the feature off at your host, or allow it in security.cspSources.`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
// Generated files must match too: the feed, Markdown copies and optimized images.
|
|
226
|
+
const generated = [...model.files.keys()].filter((name) => name.endsWith("feed.xml") || name === "llms.txt" || name === "llms-full.txt" || (name.endsWith(".md") && !name.includes("/_")));
|
|
227
|
+
for (const name of generated) {
|
|
228
|
+
const live = await get(`${origin}/${name}`);
|
|
229
|
+
const local = model.files.get(name);
|
|
230
|
+
const norm = (t) => t.replace(/\s+/g, "");
|
|
231
|
+
if (live.status !== 200)
|
|
232
|
+
differences.push({ route: `/${name}`, severity: "error", kind: "unreachable", detail: live.status ? `HTTP ${live.status}` : "no response" });
|
|
233
|
+
else if (norm(live.body) !== norm(local)) {
|
|
234
|
+
// Any URL in the live file that isn't in the build is treated like an added link.
|
|
235
|
+
const known = textLinks(local);
|
|
236
|
+
const added = [...textLinks(live.body)].filter((u) => !known.has(u));
|
|
237
|
+
const spam = spamHits(live.body).filter((h) => !spamHits(local).includes(h));
|
|
238
|
+
if (added.length)
|
|
239
|
+
differences.push({ route: `/${name}`, severity: "error", kind: "links-added", detail: `Links in the live file that aren't in your build: ${added.slice(0, 5).join(", ")}` });
|
|
240
|
+
// Generated files are the same on every build, so any difference means the live file was changed
|
|
241
|
+
// (or is out of date): an error, however the change is written.
|
|
242
|
+
differences.push({ route: `/${name}`, severity: "error", kind: "text-changed", detail: `The live file differs from your build${spam.length ? ` and contains spam words (${spam.join(", ")})` : ""}. Redeploy, and if you didn't change it, check who can deploy your site.` });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const images = new Set(model.pages.flatMap((p) => [...p.assets.keys()]));
|
|
246
|
+
for (const image of images) {
|
|
247
|
+
const live = await get(`${origin}/${image}`);
|
|
248
|
+
if (live.status !== 200)
|
|
249
|
+
differences.push({ route: `/${image}`, severity: "error", kind: "unreachable", detail: `Optimized image missing on the live site (HTTP ${live.status || "no response"}); browsers using srcset would show a broken image.` });
|
|
250
|
+
}
|
|
251
|
+
let crawlersChecked = 0;
|
|
252
|
+
if (options.crawlers !== false) {
|
|
253
|
+
const { checkCrawlability } = await import("./crawlability.js");
|
|
254
|
+
const crawl = await checkCrawlability(origin, { fetch: doFetch });
|
|
255
|
+
differences.push(...crawl.differences);
|
|
256
|
+
crawlersChecked = crawl.crawlersChecked;
|
|
257
|
+
}
|
|
258
|
+
differences.sort((a, b) => (a.severity === b.severity ? a.route.localeCompare(b.route) : a.severity === "error" ? -1 : 1));
|
|
259
|
+
return { ok: !differences.some((d) => d.severity === "error"), url: origin, pagesChecked: model.pages.length, crawlersChecked, differences };
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
await loader.close();
|
|
263
|
+
}
|
|
264
|
+
}
|