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,434 @@
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
+ import { externalHost, hiddenReason, isEmptyLink, LINK_LOCK, linkTarget, outboundHosts, readLinkLock, spamHits } from "./integrity.js";
7
+ const DOCS = {
8
+ title: "https://developers.google.com/search/docs/appearance/title-link",
9
+ description: "https://developers.google.com/search/docs/appearance/snippet",
10
+ canonical: "https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls",
11
+ structuredData: "https://developers.google.com/search/docs/appearance/structured-data/sd-policies",
12
+ article: "https://developers.google.com/search/docs/appearance/structured-data/article",
13
+ imgAlt: "https://www.w3.org/WAI/tutorials/images/",
14
+ lang: "https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html",
15
+ linkPurpose: "https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html",
16
+ robots: "https://developers.google.com/search/docs/crawling-indexing/robots/intro",
17
+ yamlFlow: "https://yaml.org/spec/1.2.2/#74-flow-collection-styles",
18
+ };
19
+ /** Paths of frontmatter values that came out empty (null), e.g. "faq.items[3].Claude and other AI search?". */
20
+ export function emptyValues(value, at = "") {
21
+ if (value === null)
22
+ return [at || "(frontmatter)"];
23
+ if (Array.isArray(value))
24
+ return value.flatMap((v, i) => emptyValues(v, `${at}[${i}]`));
25
+ if (typeof value === "object" && value !== undefined) {
26
+ return Object.entries(value).flatMap(([k, v]) => emptyValues(v, at ? `${at}.${k}` : k));
27
+ }
28
+ return [];
29
+ }
30
+ /**
31
+ * Static checks on the rendered site (nothing is written to disk).
32
+ * Passing checks mean the output is technically sound; they do not guarantee
33
+ * indexing, rankings, rich results or AI citations.
34
+ */
35
+ export async function check(root, options = {}) {
36
+ root = path.resolve(root);
37
+ const loader = await createBuildLoader(root);
38
+ try {
39
+ const model = await renderSite(root, loader.load, { collectErrors: true });
40
+ const findings = checkSite(root, model);
41
+ const errors = findings.filter((f) => f.severity === "error").length;
42
+ const warnings = findings.length - errors;
43
+ return {
44
+ ok: errors === 0 && (!options.strict || warnings === 0),
45
+ errors,
46
+ warnings,
47
+ pagesChecked: model.pages.length + (model.notFound ? 1 : 0) + model.pageErrors.length,
48
+ findings,
49
+ };
50
+ }
51
+ finally {
52
+ await loader.close();
53
+ }
54
+ }
55
+ export function checkSite(root, model) {
56
+ const findings = model.warnings.map((message) => ({ rule: "config", severity: "warning", message }));
57
+ for (const e of model.pageErrors)
58
+ findings.push({ rule: "page-error", severity: "error", message: e.message, source: e.source });
59
+ const outputs = outputPaths(root, model);
60
+ // Pages that failed to render are already reported; links to them aren't broken links on top of that.
61
+ for (const e of model.pageErrors)
62
+ if (e.route)
63
+ outputs.add(`${e.route}index.html`);
64
+ const drafts = new Set(model.draftRoutes);
65
+ const pages = model.notFound ? [...model.pages, model.notFound] : model.pages;
66
+ for (const page of pages) {
67
+ const add = (f) => findings.push({ ...f, page: page.route, source: page.source });
68
+ checkPage(page, parse(page.html), model, outputs, drafts, add);
69
+ }
70
+ findings.push(...checkDuplicates(listedPages(model)));
71
+ findings.push(...checkRobots(model));
72
+ findings.push(...checkRedirectTargets(model, outputs));
73
+ findings.push(...checkOutboundLinks(root, model));
74
+ return findings;
75
+ }
76
+ function checkPage(page, doc, model, outputs, drafts, add) {
77
+ const indexable = !page.meta.noindex;
78
+ if (model.site.security.spamPatterns) {
79
+ const text = `${doc.querySelector("body")?.text ?? ""} ${doc.querySelectorAll("a[href]").map((a) => a.getAttribute("href")).join(" ")}`;
80
+ const hits = spamHits(text);
81
+ if (hits.length) {
82
+ add({
83
+ rule: "spam-pattern",
84
+ severity: "warning",
85
+ message: `Page contains words common in injected spam (${hits.join(", ")}). If you didn't write them, check this page's history. Turn off with security.spamPatterns: false.`,
86
+ });
87
+ }
88
+ }
89
+ for (const key of emptyValues(page.frontmatter)) {
90
+ add({
91
+ rule: "frontmatter-empty",
92
+ severity: "warning",
93
+ message: `Frontmatter "${key}" has no value. If you meant to write text there, check for a missing value; inside { } a comma or colon splits the text, so wrap it in quotes.`,
94
+ docs: DOCS.yamlFlow,
95
+ });
96
+ }
97
+ if (!doc.querySelector("html")?.getAttribute("lang")) {
98
+ add({ rule: "html-lang", severity: "error", message: "<html> has no lang attribute.", docs: DOCS.lang });
99
+ }
100
+ const titles = doc.querySelectorAll("title");
101
+ if (titles.length !== 1 || !titles[0].text.trim()) {
102
+ add({ rule: "title", severity: "error", message: `Expected exactly one non-empty <title>, found ${titles.length}.`, docs: DOCS.title });
103
+ }
104
+ if (indexable && !doc.querySelector('meta[name="description"]')?.getAttribute("content")) {
105
+ add({
106
+ rule: "meta-description",
107
+ severity: "warning",
108
+ message: "No meta description. Add `description` to the frontmatter or site config.",
109
+ docs: DOCS.description,
110
+ });
111
+ }
112
+ if (!doc.querySelector("h1")) {
113
+ add({ rule: "h1", severity: "warning", message: "Page has no <h1> heading." });
114
+ }
115
+ for (const img of doc.querySelectorAll("img")) {
116
+ const src = img.getAttribute("src") ?? "";
117
+ if (img.getAttribute("alt") === undefined) {
118
+ add({ rule: "img-alt", severity: "error", message: `<img src="${src}"> has no alt attribute (use alt="" for decorative images).`, docs: DOCS.imgAlt });
119
+ }
120
+ if (!img.getAttribute("width") || !img.getAttribute("height")) {
121
+ add({ rule: "img-dimensions", severity: "warning", message: `<img src="${src}"> has no width/height; this can cause layout shift.` });
122
+ }
123
+ }
124
+ for (const el of doc.querySelectorAll("[href], [src], [action], [formaction]")) {
125
+ for (const attr of ["href", "src", "action", "formaction"]) {
126
+ const value = el.getAttribute(attr);
127
+ if (value && /^\s*(javascript|vbscript|data:text\/html)/i.test(value.replace(/[\u0000-\u0020]/g, ""))) {
128
+ 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.` });
129
+ }
130
+ }
131
+ }
132
+ const site = model.site;
133
+ if (site.deploy.target === "cloudflare-pages" && site.security.headers && site.security.csp === "enforce") {
134
+ for (const el of doc.querySelectorAll("*")) {
135
+ const handler = Object.keys(el.attributes).find((a) => /^on[a-z]+$/i.test(a));
136
+ if (handler) {
137
+ add({
138
+ rule: "csp-inline-handler",
139
+ severity: "warning",
140
+ message: `<${el.tagName.toLowerCase()} ${handler}="…"> is an inline event handler; the Content Security Policy blocks it. Use addEventListener in a script file.`,
141
+ });
142
+ }
143
+ }
144
+ }
145
+ for (const a of doc.querySelectorAll("a[href]")) {
146
+ const href = a.getAttribute("href");
147
+ checkHiddenLink(a, href, model, add);
148
+ if (!a.text.trim() && !a.getAttribute("aria-label") && !a.querySelector("img[alt]:not([alt=''])")) {
149
+ add({ rule: "link-text", severity: "warning", message: `Link to "${href}" has no accessible text.`, docs: DOCS.linkPurpose });
150
+ }
151
+ const target = internalPath(href, page.route, model.site.site);
152
+ if (target === undefined)
153
+ continue;
154
+ if (target === MALFORMED) {
155
+ add({ rule: "link-broken", severity: "error", message: `Link to "${href}" is a malformed URL.` });
156
+ continue;
157
+ }
158
+ const resolved = resolveOutput(target, outputs);
159
+ if (resolved === "missing") {
160
+ const draft = drafts.has(target.endsWith("/") ? target : `${target}/`);
161
+ add({
162
+ rule: "link-broken",
163
+ severity: "error",
164
+ message: draft ? `Link to "${href}" points to a draft page, which is not published.` : `Link to "${href}" does not match any page or file.`,
165
+ });
166
+ }
167
+ else if (resolved === "needs-slash") {
168
+ add({ rule: "link-trailing-slash", severity: "warning", message: `Link to "${href}" redirects; use "${target}/".` });
169
+ }
170
+ }
171
+ // SVG links (a xlink:href) aren't in the loop above; they get the hidden-link check too.
172
+ for (const a of doc.querySelectorAll("a")) {
173
+ if (!a.hasAttribute("href") && a.getAttribute("xlink:href"))
174
+ checkHiddenLink(a, linkTarget(a), model, add);
175
+ }
176
+ for (const [selector, attr] of [
177
+ ["img[src]", "src"],
178
+ ["script[src]", "src"],
179
+ ["link[href]:not([rel=canonical]):not([rel=alternate])", "href"],
180
+ ]) {
181
+ for (const el of doc.querySelectorAll(selector)) {
182
+ const value = el.getAttribute(attr);
183
+ const target = internalPath(value, page.route, model.site.site);
184
+ if (target !== undefined && (target === MALFORMED || resolveOutput(target, outputs) === "missing")) {
185
+ add({ rule: "asset-missing", severity: "error", message: `<${el.tagName.toLowerCase()} ${attr}="${value}"> does not exist in the build output.` });
186
+ }
187
+ }
188
+ }
189
+ const canonicals = doc.querySelectorAll('link[rel="canonical"]');
190
+ if (canonicals.length > 1) {
191
+ add({ rule: "canonical", severity: "error", message: `Found ${canonicals.length} canonical links; use one.`, docs: DOCS.canonical });
192
+ }
193
+ for (const link of canonicals) {
194
+ const href = link.getAttribute("href") ?? "";
195
+ if (!/^https?:\/\//.test(href)) {
196
+ add({ rule: "canonical", severity: "error", message: `Canonical "${href}" is not an absolute URL.`, docs: DOCS.canonical });
197
+ continue;
198
+ }
199
+ const target = internalPath(href, page.route, model.site.site);
200
+ if (target === undefined) {
201
+ add({ rule: "canonical", severity: "warning", message: `Canonical "${href}" points to another site; search engines may credit that site instead of yours.`, docs: DOCS.canonical });
202
+ }
203
+ else if (target === MALFORMED || resolveOutput(target, outputs) !== "ok") {
204
+ add({ rule: "canonical", severity: "error", message: `Canonical "${href}" does not point to a published page.`, docs: DOCS.canonical });
205
+ }
206
+ }
207
+ for (const script of doc.querySelectorAll('script[type="application/ld+json"]')) {
208
+ checkJsonLd(script.rawText, add);
209
+ }
210
+ checkAnalytics(page.html, doc, model, add);
211
+ }
212
+ function checkAnalytics(html, doc, model, add) {
213
+ const loaders = html.match(/googletagmanager\.com\/(gtag\/js|gtm\.js)/g) ?? [];
214
+ if (loaders.length > 1) {
215
+ add({
216
+ rule: "analytics-duplicate",
217
+ severity: "error",
218
+ message: `Page loads Google tags ${loaders.length} times (${loaders.join(", ")}); this double-counts. Remove the layout's tag or the integrations config.`,
219
+ });
220
+ }
221
+ const { ga4, gtm, plausible } = model.site.integrations;
222
+ // Each provider that receives events must declare them.
223
+ const receivers = [
224
+ ga4?.measurementId && { name: "ga4", events: ga4.events },
225
+ gtm?.containerId && { name: "gtm", events: gtm.events },
226
+ plausible && { name: "plausible", events: plausible.events },
227
+ ].filter((r) => Boolean(r));
228
+ for (const el of doc.querySelectorAll("[data-vage-event]")) {
229
+ const name = el.getAttribute("data-vage-event");
230
+ if (!receivers.length) {
231
+ add({ rule: "analytics-event", severity: "warning", message: `data-vage-event="${name}" is used but no analytics integration is configured.` });
232
+ continue;
233
+ }
234
+ const missing = receivers.filter((r) => !r.events[name]);
235
+ if (missing.length) {
236
+ add({
237
+ rule: "analytics-event",
238
+ severity: "error",
239
+ message: `Event "${name}" is not declared. Add it to ${missing.map((r) => `integrations.${r.name}.events`).join(" and ")} in vage.config.ts.`,
240
+ });
241
+ continue;
242
+ }
243
+ const params = Object.keys(el.attributes)
244
+ .filter((a) => a.startsWith("data-vage-param-"))
245
+ .map((a) => a.slice("data-vage-param-".length).replace(/-/g, "_"));
246
+ for (const param of params.filter((p) => receivers.some((r) => !r.events[name].params.includes(p)))) {
247
+ add({ rule: "analytics-event", severity: "error", message: `Event "${name}" has undeclared parameter "${param}". Declare it in its params list.` });
248
+ }
249
+ }
250
+ }
251
+ function checkJsonLd(raw, add) {
252
+ let data;
253
+ try {
254
+ data = JSON.parse(raw);
255
+ }
256
+ catch (err) {
257
+ add({ rule: "jsonld", severity: "error", message: `JSON-LD is not valid JSON: ${err.message}`, docs: DOCS.structuredData });
258
+ return;
259
+ }
260
+ const root = data;
261
+ if (typeof root["@context"] !== "string" || !/^https?:\/\/schema\.org\/?$/.test(root["@context"])) {
262
+ add({ rule: "jsonld", severity: "error", message: 'JSON-LD needs "@context": "https://schema.org".', docs: DOCS.structuredData });
263
+ }
264
+ const items = (Array.isArray(root["@graph"]) ? root["@graph"] : [root]);
265
+ for (const item of items) {
266
+ if (typeof item["@type"] !== "string") {
267
+ add({ rule: "jsonld", severity: "error", message: 'A JSON-LD item has no "@type".', docs: DOCS.structuredData });
268
+ }
269
+ else if (item["@type"] === "Article") {
270
+ const missing = ["author", "datePublished", "image"].filter((k) => item[k] === undefined);
271
+ if (missing.length) {
272
+ add({
273
+ rule: "jsonld-article",
274
+ severity: "warning",
275
+ message: `Article JSON-LD lacks properties Google recommends: ${missing.join(", ")}. Add them to the frontmatter only if they are true.`,
276
+ docs: DOCS.article,
277
+ });
278
+ }
279
+ }
280
+ }
281
+ }
282
+ function checkDuplicates(pages) {
283
+ const findings = [];
284
+ for (const [rule, get] of [
285
+ ["title-duplicate", (p) => p.frontmatter.title],
286
+ ["description-duplicate", (p) => p.frontmatter.description],
287
+ ]) {
288
+ const seen = new Map();
289
+ for (const p of pages) {
290
+ const value = get(p);
291
+ if (value)
292
+ seen.set(value, [...(seen.get(value) ?? []), p.route]);
293
+ }
294
+ for (const [value, routes] of seen) {
295
+ if (routes.length > 1) {
296
+ findings.push({ rule, severity: "warning", message: `"${value}" is used by ${routes.join(", ")}. Make each page's ${rule.split("-")[0]} unique.` });
297
+ }
298
+ }
299
+ }
300
+ return findings;
301
+ }
302
+ /** Links visitors can't see but search engines can: the signature of injected spam. */
303
+ function checkHiddenLink(a, href, model, add) {
304
+ const external = externalHost(href, model.site.site) !== undefined;
305
+ const hidden = hiddenReason(a) ?? (external && isEmptyLink(a) ? { reason: "empty (no text or image)", strength: "strong" } : undefined);
306
+ if (!hidden)
307
+ return;
308
+ add({
309
+ rule: "link-hidden",
310
+ severity: external && hidden.strength === "strong" ? "error" : "warning",
311
+ message: `Link to "${href}" is ${hidden.reason}: visitors can't see it, but search engines can. Hidden links are a common sign of injected spam. Remove it, or show it.`,
312
+ });
313
+ }
314
+ /**
315
+ * Outbound link guard: with security.outboundLinks "lock", every external domain must be
316
+ * listed in links.lock.json. A domain nobody approved is how injected spam usually shows up.
317
+ */
318
+ function checkOutboundLinks(root, model) {
319
+ if (model.site.security.outboundLinks === "off")
320
+ return [];
321
+ const hosts = outboundHosts(model);
322
+ const lock = readLinkLock(root);
323
+ if (!lock && model.site.security.outboundLinks === "require") {
324
+ return [{ rule: "links-lock-missing", severity: "error", message: `${LINK_LOCK} is missing, but security.outboundLinks is "require". Restore it from git, or have the site owner recreate it with "npx vage links approve --all" after reviewing "npx vage links".` }];
325
+ }
326
+ if (!lock) {
327
+ return hosts.size
328
+ ? [{ rule: "links-lock-missing", severity: "warning", message: `The site links to ${hosts.size} external domain(s) and has no ${LINK_LOCK}. The site owner should review them with "npx vage links" and approve them with "npx vage links approve --all"; after that, links to any new domain fail the check.` }]
329
+ : [];
330
+ }
331
+ const approved = new Set(lock.domains);
332
+ const findings = [];
333
+ for (const [host, pages] of hosts) {
334
+ if (approved.has(host))
335
+ continue;
336
+ const where = pages.map((p) => p.route);
337
+ findings.push({
338
+ rule: "link-unapproved",
339
+ severity: "error",
340
+ message: `Links to ${host} (on ${where.slice(0, 3).join(", ")}${where.length > 3 ? ` and ${where.length - 3} more` : ""}) aren't approved in ${LINK_LOCK}. If the site owner added them on purpose, they approve with "npx vage links approve ${host}". Otherwise remove them: unapproved links are how injected spam usually shows up.`,
341
+ page: pages[0].route,
342
+ source: pages[0].source,
343
+ });
344
+ }
345
+ return findings;
346
+ }
347
+ /** Redirects to a page on this site must land on something that exists. */
348
+ function checkRedirectTargets(model, outputs) {
349
+ const findings = [];
350
+ for (const r of model.redirects) {
351
+ const target = internalPath(r.to, "/", model.site.site);
352
+ if (target === undefined)
353
+ continue;
354
+ if (target === MALFORMED || resolveOutput(target, outputs) === "missing") {
355
+ findings.push({ rule: "redirect-target", severity: "error", message: `Redirect from "${r.from}" points to "${r.to}", which isn't a page or file on this site.`, source: "vage.config.ts" });
356
+ }
357
+ }
358
+ return findings;
359
+ }
360
+ function checkRobots(model) {
361
+ const robots = model.files.get("robots.txt");
362
+ if (!robots)
363
+ return [];
364
+ // Find the "*" group and look for a blanket Disallow.
365
+ const groups = robots.split(/\n\s*\n/);
366
+ const star = groups.find((g) => /^User-agent:\s*\*\s*$/im.test(g));
367
+ if (star && /^Disallow:\s*\/\s*$/im.test(star) && model.pages.some((p) => !p.meta.noindex)) {
368
+ return [
369
+ {
370
+ rule: "robots-blocks-all",
371
+ severity: "warning",
372
+ message: "robots.txt disallows all crawlers (User-agent: * / Disallow: /) while pages are indexable.",
373
+ source: "robots.txt",
374
+ docs: DOCS.robots,
375
+ },
376
+ ];
377
+ }
378
+ return [];
379
+ }
380
+ /** Every path the build would write, as "/path" strings. */
381
+ function outputPaths(root, model) {
382
+ const out = new Set();
383
+ for (const p of model.pages)
384
+ out.add(`/${p.outFile}`);
385
+ if (model.notFound)
386
+ out.add("/404.html");
387
+ for (const r of model.redirects)
388
+ out.add(`/${r.outFile}`);
389
+ for (const name of model.files.keys())
390
+ out.add(`/${name}`);
391
+ const walk = (dir, prefix) => {
392
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
393
+ if (e.isDirectory())
394
+ walk(path.join(dir, e.name), `${prefix}/${e.name}`);
395
+ else
396
+ out.add(`${prefix}/${e.name}`);
397
+ }
398
+ };
399
+ const publicDir = path.join(root, "public");
400
+ if (fs.existsSync(publicDir))
401
+ walk(publicDir, "");
402
+ return out;
403
+ }
404
+ const MALFORMED = "\u0000malformed";
405
+ /** Returns the site-relative path for internal URLs, undefined for external ones, MALFORMED when undecodable. */
406
+ function internalPath(href, base, site) {
407
+ if (/^(mailto:|tel:|javascript:|vbscript:|data:|#)/i.test(href.trim()) || href === "")
408
+ return undefined;
409
+ let url;
410
+ try {
411
+ url = new URL(href, `http://internal.invalid${base}`);
412
+ }
413
+ catch {
414
+ return MALFORMED;
415
+ }
416
+ const isInternal = url.host === "internal.invalid" || (site !== undefined && url.origin === site);
417
+ if (!isInternal)
418
+ return undefined;
419
+ try {
420
+ return decodeURIComponent(url.pathname);
421
+ }
422
+ catch {
423
+ return MALFORMED;
424
+ }
425
+ }
426
+ function resolveOutput(target, outputs) {
427
+ if (target.endsWith("/"))
428
+ return outputs.has(`${target}index.html`) ? "ok" : "missing";
429
+ if (outputs.has(target))
430
+ return "ok";
431
+ if (!path.posix.extname(target) && outputs.has(`${target}/index.html`))
432
+ return "needs-slash";
433
+ return "missing";
434
+ }
@@ -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 };