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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -2
  3. package/dist/audit/a11y.d.ts +13 -0
  4. package/dist/audit/a11y.js +33 -0
  5. package/dist/audit/audit.d.ts +31 -0
  6. package/dist/audit/audit.js +143 -0
  7. package/dist/audit/budgets.d.ts +22 -0
  8. package/dist/audit/budgets.js +106 -0
  9. package/dist/audit/lighthouse.d.ts +3 -0
  10. package/dist/audit/lighthouse.js +48 -0
  11. package/dist/audit/remote.d.ts +65 -0
  12. package/dist/audit/remote.js +123 -0
  13. package/dist/audit/vitals.d.ts +28 -0
  14. package/dist/audit/vitals.js +18 -0
  15. package/dist/checks/check.d.ts +31 -0
  16. package/dist/checks/check.js +434 -0
  17. package/dist/checks/integrity.d.ts +36 -0
  18. package/dist/checks/integrity.js +145 -0
  19. package/dist/checks/links.d.ts +18 -0
  20. package/dist/checks/links.js +47 -0
  21. package/dist/checks/verify.d.ts +35 -0
  22. package/dist/checks/verify.js +167 -0
  23. package/dist/cli/create.d.ts +10 -0
  24. package/dist/cli/create.js +39 -0
  25. package/dist/cli/index.d.ts +2 -0
  26. package/dist/cli/index.js +710 -0
  27. package/dist/cli/info.d.ts +82 -0
  28. package/dist/cli/info.js +100 -0
  29. package/dist/content/images.d.ts +23 -0
  30. package/dist/content/images.js +96 -0
  31. package/dist/content/markdown.d.ts +28 -0
  32. package/dist/content/markdown.js +56 -0
  33. package/dist/content/sanitize.d.ts +16 -0
  34. package/dist/content/sanitize.js +57 -0
  35. package/dist/core/build.d.ts +25 -0
  36. package/dist/core/build.js +54 -0
  37. package/dist/core/clone.d.ts +6 -0
  38. package/dist/core/clone.js +16 -0
  39. package/dist/core/config.d.ts +180 -0
  40. package/dist/core/config.js +260 -0
  41. package/dist/core/errors.d.ts +7 -0
  42. package/dist/core/errors.js +12 -0
  43. package/dist/core/html.d.ts +24 -0
  44. package/dist/core/html.js +42 -0
  45. package/dist/core/loader.d.ts +10 -0
  46. package/dist/core/loader.js +16 -0
  47. package/dist/core/pages.d.ts +40 -0
  48. package/dist/core/pages.js +93 -0
  49. package/dist/core/project-config.d.ts +3 -0
  50. package/dist/core/project-config.js +14 -0
  51. package/dist/core/render.d.ts +53 -0
  52. package/dist/core/render.js +104 -0
  53. package/dist/core/routes.d.ts +24 -0
  54. package/dist/core/routes.js +70 -0
  55. package/dist/core/site.d.ts +42 -0
  56. package/dist/core/site.js +182 -0
  57. package/dist/core/validate.d.ts +12 -0
  58. package/dist/core/validate.js +36 -0
  59. package/dist/deploy/cloudflare-pages.d.ts +20 -0
  60. package/dist/deploy/cloudflare-pages.js +42 -0
  61. package/dist/deploy/cloudflare-workers.d.ts +59 -0
  62. package/dist/deploy/cloudflare-workers.js +223 -0
  63. package/dist/deploy/git.d.ts +20 -0
  64. package/dist/deploy/git.js +55 -0
  65. package/dist/deploy/github-pages.d.ts +33 -0
  66. package/dist/deploy/github-pages.js +99 -0
  67. package/dist/deploy/headers.d.ts +11 -0
  68. package/dist/deploy/headers.js +103 -0
  69. package/dist/deploy/publish.d.ts +45 -0
  70. package/dist/deploy/publish.js +148 -0
  71. package/dist/deploy/redirects.d.ts +7 -0
  72. package/dist/deploy/redirects.js +18 -0
  73. package/dist/deploy/secrets.d.ts +6 -0
  74. package/dist/deploy/secrets.js +66 -0
  75. package/dist/dev/guard.d.ts +9 -0
  76. package/dist/dev/guard.js +57 -0
  77. package/dist/dev/listen.d.ts +6 -0
  78. package/dist/dev/listen.js +13 -0
  79. package/dist/dev/preview.d.ts +6 -0
  80. package/dist/dev/preview.js +72 -0
  81. package/dist/dev/server.d.ts +8 -0
  82. package/dist/dev/server.js +104 -0
  83. package/dist/discovery/crawlers.d.ts +13 -0
  84. package/dist/discovery/crawlers.js +2 -0
  85. package/dist/discovery/llms.d.ts +12 -0
  86. package/dist/discovery/llms.js +19 -0
  87. package/dist/discovery/robots.d.ts +8 -0
  88. package/dist/discovery/robots.js +40 -0
  89. package/dist/discovery/sitemap.d.ts +8 -0
  90. package/dist/discovery/sitemap.js +11 -0
  91. package/dist/guidelines/monitor.d.ts +55 -0
  92. package/dist/guidelines/monitor.js +126 -0
  93. package/dist/guidelines/pack.d.ts +41 -0
  94. package/dist/guidelines/pack.js +13 -0
  95. package/dist/guidelines/rule-pack.json +611 -0
  96. package/dist/index.d.ts +14 -0
  97. package/dist/index.js +12 -0
  98. package/dist/integrations/api-keys.d.ts +26 -0
  99. package/dist/integrations/api-keys.js +65 -0
  100. package/dist/integrations/ga4-data.d.ts +13 -0
  101. package/dist/integrations/ga4-data.js +39 -0
  102. package/dist/integrations/google-auth.d.ts +47 -0
  103. package/dist/integrations/google-auth.js +157 -0
  104. package/dist/integrations/http.d.ts +15 -0
  105. package/dist/integrations/http.js +40 -0
  106. package/dist/integrations/indexnow.d.ts +18 -0
  107. package/dist/integrations/indexnow.js +38 -0
  108. package/dist/integrations/search-console.d.ts +25 -0
  109. package/dist/integrations/search-console.js +39 -0
  110. package/dist/integrations/tags.d.ts +22 -0
  111. package/dist/integrations/tags.js +67 -0
  112. package/dist/seo/head.d.ts +20 -0
  113. package/dist/seo/head.js +85 -0
  114. package/dist/seo/jsonld.d.ts +35 -0
  115. package/dist/seo/jsonld.js +31 -0
  116. package/dist/seo/urls.d.ts +3 -0
  117. package/dist/seo/urls.js +10 -0
  118. package/package.json +81 -4
  119. package/templates/starter/.env.example +12 -0
  120. package/templates/starter/.github/dependabot.yml +11 -0
  121. package/templates/starter/.github/workflows/ci.yml +30 -0
  122. package/templates/starter/.github/workflows/verify.yml +32 -0
  123. package/templates/starter/AGENTS.md +166 -0
  124. package/templates/starter/CLAUDE.md +3 -0
  125. package/templates/starter/_gitignore +8 -0
  126. package/templates/starter/links.lock.json +4 -0
  127. package/templates/starter/package.json +16 -0
  128. package/templates/starter/public/apple-touch-icon.png +0 -0
  129. package/templates/starter/public/favicon.ico +0 -0
  130. package/templates/starter/public/favicon.svg +1 -0
  131. package/templates/starter/src/layouts/blog.ts +13 -0
  132. package/templates/starter/src/layouts/default.ts +38 -0
  133. package/templates/starter/src/pages/404.md +7 -0
  134. package/templates/starter/src/pages/about.md +8 -0
  135. package/templates/starter/src/pages/blog/first-post.md +10 -0
  136. package/templates/starter/src/pages/blog/index.md +7 -0
  137. package/templates/starter/src/pages/index.md +8 -0
  138. package/templates/starter/vage.config.ts +38 -0
@@ -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,35 @@
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
+ text: string;
13
+ /** Spam vocabulary in the visible text (read with spaces intact). */
14
+ spam: string[];
15
+ }
16
+ export interface Difference {
17
+ route: string;
18
+ severity: "error" | "warning";
19
+ kind: "unreachable" | "links-added" | "links-removed" | "scripts-added" | "text-changed" | "sitemap-extra" | "robots-changed";
20
+ detail: string;
21
+ }
22
+ export interface VerifyResult {
23
+ ok: boolean;
24
+ url: string;
25
+ pagesChecked: number;
26
+ differences: Difference[];
27
+ }
28
+ /** What matters for injected spam: where the page sends people, what runs on it, and its visible text, independent of formatting. */
29
+ export declare function fingerprint(html: string, pageUrl: string): PageFingerprint;
30
+ export declare function compare(route: string, local: PageFingerprint, live: PageFingerprint): Difference[];
31
+ export declare function verifySite(root: string, options?: {
32
+ url?: string;
33
+ fetch?: typeof fetch;
34
+ concurrency?: number;
35
+ }): Promise<VerifyResult>;
@@ -0,0 +1,167 @@
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
+ /** Scripts a CDN adds by itself (Cloudflare email protection, Web Analytics), not content changes. */
11
+ const CDN_SCRIPT = /\/cdn-cgi\/|static\.cloudflareinsights\.com/;
12
+ const TARGETS = [
13
+ ["a, area", "", (el) => el.getAttribute("href") ?? el.getAttribute("xlink:href")],
14
+ ["link[href]", "link:", (el) => el.getAttribute("href")],
15
+ ['meta[property="og:url"]', "og:url:", (el) => el.getAttribute("content")],
16
+ ["meta[http-equiv]", "refresh:", (el) => (/^refresh$/i.test(el.getAttribute("http-equiv") ?? "") ? /url\s*=\s*['"]?([^'";]+)/i.exec(el.getAttribute("content") ?? "")?.[1] : undefined)],
17
+ ["iframe[src], embed[src], frame[src]", "frame:", (el) => el.getAttribute("src")],
18
+ ["object[data]", "object:", (el) => el.getAttribute("data")],
19
+ ["form[action]", "form:", (el) => el.getAttribute("action")],
20
+ ];
21
+ /** What matters for injected spam: where the page sends people, what runs on it, and its visible text, independent of formatting. */
22
+ export function fingerprint(html, pageUrl) {
23
+ const doc = parse(html);
24
+ const abs = (href) => {
25
+ try {
26
+ return new URL(href.trim(), pageUrl).href;
27
+ }
28
+ catch {
29
+ return href.trim();
30
+ }
31
+ };
32
+ const links = new Set();
33
+ for (const [selector, prefix, read] of TARGETS) {
34
+ for (const el of doc.querySelectorAll(selector)) {
35
+ const value = read(el);
36
+ if (!value || value.trim().startsWith("mailto:"))
37
+ continue;
38
+ const url = abs(value);
39
+ // Cloudflare's email protection rewrites mailto: links; not a content change.
40
+ if (/\/cdn-cgi\//.test(url))
41
+ continue;
42
+ links.add(prefix + url);
43
+ }
44
+ }
45
+ const scripts = new Set();
46
+ for (const el of doc.querySelectorAll("script")) {
47
+ const src = el.getAttribute("src");
48
+ if (src) {
49
+ const url = abs(src);
50
+ if (!CDN_SCRIPT.test(url))
51
+ scripts.add(`src:${url}`);
52
+ }
53
+ else if (el.getAttribute("type") === "application/ld+json")
54
+ scripts.add(`json-ld:${sha(el.text.replace(/\s+/g, ""))}`);
55
+ else
56
+ scripts.add(`inline:${sha(el.text.trim())}`);
57
+ }
58
+ for (const el of doc.querySelectorAll("*")) {
59
+ for (const [name, value] of Object.entries(el.attributes)) {
60
+ if (/^on/i.test(name))
61
+ scripts.add(`handler:${el.tagName.toLowerCase()}.${name.toLowerCase()}:${sha(value)}`);
62
+ }
63
+ }
64
+ for (const el of doc.querySelectorAll("script, style, noscript, template"))
65
+ el.remove();
66
+ // Whitespace between elements depends on formatting, not content, so it's ignored entirely.
67
+ const visible = (doc.querySelector("body") ?? doc).text;
68
+ return { links: [...links].sort(), scripts: [...scripts].sort(), text: visible.replace(/\s+/g, ""), spam: spamHits(visible.replace(/\s+/g, " ")) };
69
+ }
70
+ export function compare(route, local, live) {
71
+ const out = [];
72
+ const added = live.links.filter((l) => !local.links.includes(l));
73
+ const removed = local.links.filter((l) => !live.links.includes(l));
74
+ const scripts = live.scripts.filter((s) => !local.scripts.includes(s));
75
+ if (added.length)
76
+ 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})` : ""}` });
77
+ if (scripts.length)
78
+ 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(", ")}` });
79
+ if (removed.length)
80
+ 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})` : ""}` });
81
+ if (local.text !== live.text) {
82
+ const spam = live.spam.filter((h) => !local.spam.includes(h));
83
+ out.push(spam.length
84
+ ? { route, severity: "error", kind: "text-changed", detail: `The live page's text differs from your files and contains spam words (${spam.join(", ")}).` }
85
+ : { 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)." });
86
+ }
87
+ return out;
88
+ }
89
+ function sitemapUrls(xml) {
90
+ return [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((m) => m[1].replace(/&amp;/g, "&"));
91
+ }
92
+ export async function verifySite(root, options = {}) {
93
+ root = path.resolve(root);
94
+ const doFetch = options.fetch ?? fetch;
95
+ const loader = await createBuildLoader(root);
96
+ try {
97
+ const model = await renderSite(root, loader.load);
98
+ const origin = (options.url ?? model.site.site)?.replace(/\/+$/, "");
99
+ if (!origin || !/^https?:\/\/[^/]+$/.test(origin))
100
+ throw new VageError('Pass the live site\'s origin with --url https://example.com, or set "site" in vage.config.ts.');
101
+ const get = async (url, userAgent = "vagejs-verify") => {
102
+ try {
103
+ const res = await doFetch(url, { redirect: "follow", headers: { "user-agent": userAgent }, signal: AbortSignal.timeout(20_000) });
104
+ return { status: res.status, body: res.ok ? await res.text() : "" };
105
+ }
106
+ catch (err) {
107
+ return { status: 0, body: "", error: err.message };
108
+ }
109
+ };
110
+ const differences = [];
111
+ const queue = [...model.pages];
112
+ const worker = async () => {
113
+ for (let page = queue.shift(); page; page = queue.shift()) {
114
+ const url = `${origin}${page.route}`;
115
+ const live = await get(url);
116
+ if (live.status !== 200) {
117
+ differences.push({ route: page.route, severity: "error", kind: "unreachable", detail: live.status ? `HTTP ${live.status}` : "no response" });
118
+ continue;
119
+ }
120
+ const local = fingerprint(page.html, url);
121
+ const seen = compare(page.route, local, fingerprint(live.body, url));
122
+ differences.push(...seen);
123
+ // Hacked sites often show spam only to search engines ("cloaking"): look again as Googlebot.
124
+ const bot = await get(url, GOOGLEBOT);
125
+ if (bot.status === 200) {
126
+ for (const d of compare(page.route, local, fingerprint(bot.body, url))) {
127
+ if (!seen.some((x) => x.kind === d.kind && x.detail === d.detail))
128
+ differences.push({ ...d, detail: `Only when visited as Googlebot: ${d.detail}` });
129
+ }
130
+ }
131
+ }
132
+ };
133
+ await Promise.all(Array.from({ length: Math.max(1, options.concurrency ?? 4) }, worker));
134
+ // A planted Sitemap: line or new rules in robots.txt point crawlers at spam.
135
+ const localRobots = model.files.get("robots.txt");
136
+ if (localRobots) {
137
+ const live = await get(`${origin}/robots.txt`);
138
+ const lines = (t) => t.split(/\r?\n/).map((l) => l.replace(/#.*/, "").trim()).filter(Boolean).join("\n");
139
+ if (live.status === 200 && lines(live.body) !== lines(localRobots)) {
140
+ differences.push({ route: "/robots.txt", severity: "error", kind: "robots-changed", detail: "The live robots.txt differs from the one your build writes." });
141
+ }
142
+ }
143
+ // Pages someone added on the server announce themselves in the live sitemap.
144
+ const localSitemap = model.files.get("sitemap.xml");
145
+ if (localSitemap) {
146
+ const live = await get(`${origin}/sitemap.xml`);
147
+ if (live.status === 200) {
148
+ const known = new Set(sitemapUrls(localSitemap).map((u) => new URL(u).pathname));
149
+ const extra = sitemapUrls(live.body).filter((u) => {
150
+ try {
151
+ return !known.has(new URL(u).pathname);
152
+ }
153
+ catch {
154
+ return true;
155
+ }
156
+ });
157
+ if (extra.length)
158
+ 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})` : ""}` });
159
+ }
160
+ }
161
+ differences.sort((a, b) => (a.severity === b.severity ? a.route.localeCompare(b.route) : a.severity === "error" ? -1 : 1));
162
+ return { ok: !differences.some((d) => d.severity === "error"), url: origin, pagesChecked: model.pages.length, differences };
163
+ }
164
+ finally {
165
+ await loader.close();
166
+ }
167
+ }
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};