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.
Files changed (80) hide show
  1. package/README.md +196 -25
  2. package/dist/checks/check.d.ts +2 -0
  3. package/dist/checks/check.js +106 -2
  4. package/dist/checks/crawlability.d.ts +34 -0
  5. package/dist/checks/crawlability.js +142 -0
  6. package/dist/checks/integrity.d.ts +36 -0
  7. package/dist/checks/integrity.js +145 -0
  8. package/dist/checks/links.d.ts +18 -0
  9. package/dist/checks/links.js +47 -0
  10. package/dist/checks/verify.d.ts +42 -0
  11. package/dist/checks/verify.js +264 -0
  12. package/dist/cli/index.js +118 -10
  13. package/dist/cli/info.d.ts +46 -0
  14. package/dist/cli/info.js +65 -5
  15. package/dist/content/images.d.ts +26 -0
  16. package/dist/content/images.js +129 -0
  17. package/dist/content/markdown.d.ts +4 -0
  18. package/dist/content/markdown.js +8 -1
  19. package/dist/content/optimize.d.ts +31 -0
  20. package/dist/content/optimize.js +92 -0
  21. package/dist/content/sanitize.d.ts +18 -2
  22. package/dist/content/sanitize.js +49 -9
  23. package/dist/core/build.js +11 -0
  24. package/dist/core/clone.d.ts +6 -0
  25. package/dist/core/clone.js +16 -0
  26. package/dist/core/config.d.ts +26 -0
  27. package/dist/core/config.js +43 -4
  28. package/dist/core/dates.d.ts +2 -0
  29. package/dist/core/dates.js +4 -0
  30. package/dist/core/errors.d.ts +2 -0
  31. package/dist/core/errors.js +3 -0
  32. package/dist/core/html.d.ts +22 -0
  33. package/dist/core/html.js +31 -0
  34. package/dist/core/pages.d.ts +67 -0
  35. package/dist/core/pages.js +168 -0
  36. package/dist/core/render.d.ts +24 -1
  37. package/dist/core/render.js +69 -10
  38. package/dist/core/site.d.ts +12 -1
  39. package/dist/core/site.js +77 -18
  40. package/dist/core/validate.d.ts +2 -0
  41. package/dist/core/validate.js +6 -0
  42. package/dist/deploy/cloudflare-pages.js +3 -1
  43. package/dist/deploy/cloudflare-workers.d.ts +59 -0
  44. package/dist/deploy/cloudflare-workers.js +223 -0
  45. package/dist/deploy/github-pages.js +2 -0
  46. package/dist/deploy/headers.js +1 -1
  47. package/dist/deploy/publish.d.ts +1 -1
  48. package/dist/deploy/publish.js +3 -3
  49. package/dist/deploy/redirects.js +1 -1
  50. package/dist/deploy/secrets.js +1 -1
  51. package/dist/dev/guard.d.ts +2 -0
  52. package/dist/dev/guard.js +30 -1
  53. package/dist/dev/server.js +22 -4
  54. package/dist/discovery/feed.d.ts +21 -0
  55. package/dist/discovery/feed.js +68 -0
  56. package/dist/discovery/git-dates.d.ts +10 -0
  57. package/dist/discovery/git-dates.js +57 -0
  58. package/dist/discovery/markdown-copy.d.ts +13 -0
  59. package/dist/discovery/markdown-copy.js +112 -0
  60. package/dist/guidelines/rule-pack.json +101 -2
  61. package/dist/index.d.ts +4 -1
  62. package/dist/index.js +4 -1
  63. package/dist/integrations/api-keys.d.ts +1 -1
  64. package/dist/integrations/api-keys.js +1 -1
  65. package/dist/integrations/google-auth.js +1 -1
  66. package/dist/integrations/tags.d.ts +1 -1
  67. package/dist/integrations/tags.js +1 -1
  68. package/dist/seo/head.js +17 -1
  69. package/package.json +4 -1
  70. package/templates/starter/.github/dependabot.yml +11 -0
  71. package/templates/starter/.github/workflows/ci.yml +4 -0
  72. package/templates/starter/.github/workflows/verify.yml +34 -0
  73. package/templates/starter/AGENTS.md +45 -16
  74. package/templates/starter/links.lock.json +4 -0
  75. package/templates/starter/package.json +1 -1
  76. package/templates/starter/src/layouts/blog.ts +13 -0
  77. package/templates/starter/src/layouts/default.ts +8 -7
  78. package/templates/starter/src/pages/blog/index.md +7 -0
  79. package/templates/starter/src/pages/index.md +2 -2
  80. package/templates/starter/vage.config.ts +5 -1
package/README.md CHANGED
@@ -1,12 +1,178 @@
1
1
  <p align="center">
2
- <img src="assets/brand/vagejs-logo.svg" alt="VAGE JS" width="320">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/agevenkat/vagejs/main/assets/brand/vagejs-logo-dark.svg">
4
+ <img src="https://raw.githubusercontent.com/agevenkat/vagejs/main/assets/brand/vagejs-logo.svg" alt="vagejs" width="320">
5
+ </picture>
3
6
  </p>
4
7
 
5
- # VAGE JS
8
+ <p align="center">
9
+ <a href="https://www.npmjs.com/package/vagejs"><img src="https://img.shields.io/npm/v/vagejs?color=4F46E5&label=npm" alt="npm version"></a>
10
+ <a href="https://github.com/agevenkat/vagejs/actions/workflows/ci.yml"><img src="https://github.com/agevenkat/vagejs/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
11
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/vagejs?color=2E2A8C" alt="MIT license"></a>
12
+ </p>
13
+
14
+ # vagejs
15
+
16
+ An AI-native, static-first web framework. Write pages in Markdown, and vagejs builds a fast static site with SEO, `llms.txt`, accessibility and security handled for you. It's built so AI coding agents can use it without guessing. **Pre-release (0.x).**
17
+
18
+ - **Static by default**: plain HTML, no client JavaScript unless you add analytics.
19
+ - **SEO built in**: `robots.txt`, `sitemap.xml`, `llms.txt`, canonical URLs, Open Graph and JSON-LD, generated from your pages.
20
+ - **Checks before you ship**: `vage check` (links, SEO, structured data, accessibility) and `vage audit` (page-weight budgets, axe, optional Lighthouse and PageSpeed).
21
+ - **Safe by default**: page content is sanitized; secrets, build output and force-pushes are refused; Cloudflare deploys get a computed Content-Security-Policy.
22
+ - **Agent-friendly**: every site ships with `AGENTS.md`, and `vage info --json` describes every command and config option.
6
23
 
7
- An AI-native, static-first web framework. **Pre-release (0.x).**
24
+ ## Quick start
8
25
 
9
- ## What works today
26
+ Requires **Node.js 22.12 or newer**.
27
+
28
+ ```bash
29
+ npm create vagejs@latest my-site
30
+ cd my-site
31
+ npm install
32
+ npm run dev
33
+ ```
34
+
35
+ Open the URL it prints. Edit `src/pages/index.md` and the browser reloads.
36
+
37
+ ## Using vagejs
38
+
39
+ ### Project layout
40
+
41
+ ```text
42
+ my-site/
43
+ ├── src/
44
+ │ ├── pages/ # every .md file is a page
45
+ │ │ ├── index.md # → /
46
+ │ │ ├── about.md # → /about/
47
+ │ │ └── blog/
48
+ │ │ └── first-post.md # → /blog/first-post/
49
+ │ └── layouts/
50
+ │ └── default.ts # HTML around each page
51
+ ├── public/ # copied to the site as-is (favicons, images)
52
+ ├── vage.config.ts # site settings
53
+ └── AGENTS.md # instructions for AI coding agents
54
+ ```
55
+
56
+ ### Write a page
57
+
58
+ ```markdown
59
+ ---
60
+ title: Pricing
61
+ description: Plans and prices.
62
+ ---
63
+
64
+ # Pricing
65
+
66
+ Our plans start at …
67
+ ```
68
+
69
+ Save it as `src/pages/pricing.md` and it's served at `/pricing/`. `title` is required. `description`, `draft`, `noindex`, `canonical`, `image`, `type: article`, `author`, `datePublished` and `dateModified` are optional; see [`templates/starter/AGENTS.md`](templates/starter/AGENTS.md).
70
+
71
+ Images in `public/` used as `![Alt text](/images/photo.jpg)` get their `width` and `height` from the file, so the page doesn't jump while loading. With `images: { optimize: true }` (and `npm install --save-dev sharp`), vagejs also makes resized WebP copies of images in page content and offers them with `srcset`.
72
+
73
+ ### Navigation, feeds and AI extras
74
+
75
+ - `order:` and `navTitle:` in frontmatter, with `tree()`, `breadcrumbs()` and `neighbors()` in layouts, build sidebars, breadcrumbs and previous/next links.
76
+ - `seo: { feeds: [{ under: "/blog/" }] }` writes an RSS feed at `/blog/feed.xml`.
77
+ - `seo: { markdownCopies: true }` publishes a Markdown copy of every listed page (`/docs/cli.md`) for AI tools, without comments or raw HTML; `seo: { lastmod: "git" }` dates the sitemap from git history (CI needs `fetch-depth: 0`).
78
+
79
+ ### Write a layout
80
+
81
+ Layouts are TypeScript functions. The `html` template tag escapes every value for you; `raw()` inserts trusted HTML such as the rendered page. (Escaping doesn't check URLs, so validate link targets that come from frontmatter.) `pages` lists every published page, and `collection()` picks and sorts them:
82
+
83
+ ```ts
84
+ import { collection, html, raw, z, type Layout } from "vagejs";
85
+
86
+ // Optional: fields this layout accepts. Pages using it are checked strictly, so typos fail the build.
87
+ export const frontmatter = z.object({ intro: z.string().optional() });
88
+
89
+ const layout: Layout = ({ content, page, site, pages }) => html`<!doctype html>
90
+ <html lang="${site.lang}">
91
+ <head><title>${page.title}</title></head>
92
+ <body>
93
+ <main>${raw(content)}</main>
94
+ <h2>Latest posts</h2>
95
+ <ul>${collection(pages, { under: "/blog/", sort: "date", limit: 5 }).map((p) => html`<li><a href="${p.route}">${p.title}</a></li>`)}</ul>
96
+ </body>
97
+ </html>`;
98
+
99
+ export default layout;
100
+ ```
101
+
102
+ ### Everyday commands
103
+
104
+ | Command | What it does |
105
+ | --- | --- |
106
+ | `npm run dev` | Dev server with live reload |
107
+ | `npm run check` | Links, SEO, structured data and basic accessibility; writes nothing |
108
+ | `npm run build` | Static site in `dist/`, plus `robots.txt`, `sitemap.xml`, `llms.txt` |
109
+ | `npm run audit` | Page-weight budgets and automated accessibility (`-- --lighthouse` adds Lighthouse) |
110
+ | `npm run preview` | Serves `dist/` locally, the way a static host would |
111
+
112
+ Add `--json` to `npx vage build`, `check` or `audit` for machine-readable output. Failures exit with code 1.
113
+
114
+ ### Configure the site
115
+
116
+ `vage.config.ts`:
117
+
118
+ ```ts
119
+ import { defineConfig } from "vagejs";
120
+
121
+ export default defineConfig({
122
+ site: "https://example.com", // your real domain (used for canonical URLs and the sitemap)
123
+ title: "My Website",
124
+ description: "What this site is about.",
125
+ seo: {
126
+ robots: { aiTraining: "disallow" }, // optional: block AI training crawlers, keep search
127
+ llmsTxt: { full: false },
128
+ },
129
+ redirects: { "/old-page/": "/new-page/" },
130
+ });
131
+ ```
132
+
133
+ Unknown keys are an error, so typos don't silently do nothing. `npx vage info --json` prints the full schema.
134
+
135
+ ### Deploy
136
+
137
+ ```bash
138
+ npx vage deploy:github --remote https://github.com/you/site.git # check, build, safe commit + push (asks first)
139
+ npx vage deploy # GitHub Pages via Actions
140
+ npx vage deploy --target cloudflare-workers --project my-site # Cloudflare Workers: new version, no downtime
141
+ npx vage deploy --target cloudflare-pages --project my-site # older Cloudflare Pages projects
142
+ ```
143
+
144
+ Every deploy shows its plan and asks for confirmation (`--dry-run` to preview, `--yes` to skip). It reports success only after the host confirms.
145
+
146
+ Cloudflare now creates new static sites as Workers, so use `cloudflare-workers` for new projects. For a custom domain, add a `wrangler.jsonc` with your `routes`; vagejs uses it and switches versions without re-attaching domains, so the site stays up during deploys.
147
+
148
+ ### Analytics, Search Console and audits of live pages
149
+
150
+ Optional, and off until configured: GA4 or Google Tag Manager (consent denied by default), Plausible, Microsoft Clarity, IndexNow. `vage connect gsc|ga4` adds read-only Search Console and GA4 reports, and `vage audit --url https://your-site` runs PageSpeed and CrUX with **your own** Google API key. Details are in the generated `AGENTS.md`.
151
+
152
+ ## Updating
153
+
154
+ vagejs follows semver. While it's 0.x, a minor version (0.1 → 0.2) may include breaking changes, so read the release notes before moving up a minor version.
155
+
156
+ ```bash
157
+ npm outdated vagejs # see what's available
158
+ npm update vagejs # latest patch in your range (with ^0.3.0: any 0.3.x)
159
+ npm install vagejs@latest # move to the newest version, including a new minor
160
+ npx vage --version # confirm
161
+ npx vage check && npx vage build
162
+ ```
163
+
164
+ If your config pins the rule pack (`guidelines: { rulePack: "…" }`), a build after an update that ships new provider rules stops with a message. Review them with `npx vage guidelines`, then update the pin. New projects always use the latest version through `npm create vagejs@latest`.
165
+
166
+ ## Use with AI coding agents
167
+
168
+ Every site includes `AGENTS.md` (conventions, recipes, definition of done) and `CLAUDE.md` (imports `AGENTS.md`). Agents can discover the API instead of guessing:
169
+
170
+ ```bash
171
+ npx vage info --json # commands, options, config and frontmatter JSON Schemas
172
+ npx vage check --json # findings with file, rule and fix hints
173
+ ```
174
+
175
+ ## Command reference
10
176
 
11
177
  ```bash
12
178
  vage create <dir> [--vage-spec <spec>] [--json] # scaffold a Markdown site
@@ -16,8 +182,9 @@ vage check [--root .] [--json] [--strict] # SEO, links, JSON-LD, basic a
16
182
  vage audit [--root .] [--lighthouse] [--url U] [--strategy mobile|desktop|both] [--strict] [--json]
17
183
  # budgets + axe a11y; optional Lighthouse; PageSpeed/CrUX for deployed URLs
18
184
  vage preview [--root .] [--port N] [--host] # serve dist/ locally
19
- vage deploy:github [--remote URL] [--dry-run] [--yes] [--json] # check, build, safe commit + push (asks first)
185
+ vage deploy:github [--remote URL] [--branch B] [-m MSG] [--dry-run] [--yes] [--json] # check, build, safe commit + push (asks first)
20
186
  vage deploy [--remote URL] [--no-wait] [--dry-run] [--yes] # GitHub Pages via Actions; confirms with GitHub
187
+ vage deploy --target cloudflare-workers [--project NAME] # new Worker version at 100%; custom domains stay up
21
188
  vage deploy --target cloudflare-pages --project NAME [--branch B] # Wrangler upload; preview URL per branch
22
189
  vage indexnow --changed-since REF | --urls … | --all # IndexNow URL notification (asks first)
23
190
  vage connect gsc|ga4 / vage disconnect google # read-only Google OAuth (PKCE, loopback); token outside the repo
@@ -27,45 +194,49 @@ vage guidelines [--json] # rule pack: provider facts in
27
194
  vage info [--json] # commands + config/frontmatter JSON Schemas for agents
28
195
  ```
29
196
 
30
- Generated sites include `AGENTS.md` (conventions, recipes, definition of done) and `CLAUDE.md` (imports AGENTS.md).
197
+ ## Planned, not implemented
198
+
199
+ Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths, sitemap indexes, MCP server, AVIF images.
200
+
201
+ ## Security
202
+
203
+ Page content is sanitized, dev/preview servers serve only what a site needs, and Cloudflare deploys get security headers with a computed Content-Security-Policy. Report vulnerabilities privately: see [SECURITY.md](SECURITY.md).
31
204
 
32
- Failures exit with code 1. With `--json`, output is `{ "ok": true, ... }` or `{ "ok": false, "error": "..." }`.
205
+ ### Protection against spam injection
33
206
 
34
- See [`templates/starter/AGENTS.md`](templates/starter/AGENTS.md) for routing, frontmatter, layouts and config, and [`docs/DECISIONS.md`](docs/DECISIONS.md) for design decisions.
207
+ A static site has no admin panel, database or plugins to break into, so attackers go after your content, your repository or your host instead, usually to plant hidden links and spam pages. vagejs checks for that on every build:
35
208
 
36
- ## Getting started
209
+ | Protection | What it does |
210
+ | --- | --- |
211
+ | Outbound link lock | `links.lock.json` lists the domains your site may link to. A link to any other domain fails `vage check` (and so every deploy) until you approve it with `vage links approve <domain>`. It covers links, SVG links, canonical and `og:url`, meta refresh, iframes and forms. With `security.outboundLinks: "require"` (the default for new sites), deleting the file is an error too. |
212
+ | No hidden links | Inline styles, SVG `xlink:href` and legacy tags like `<font>` and `<marquee>` are stripped from page content, and `vage check` fails on external links hidden with CSS, `aria-hidden`, zero-size SVG or no visible content, in content and layouts. |
213
+ | Spam words | `vage check` warns about common injected-spam terms (casino, pharmacy, payday loans…). |
214
+ | Live-site verification | `vage verify` compares the live site with your files: links and other targets, scripts, event handlers, JSON-LD, text (spam words are errors), `robots.txt` and sitemap pages. It also looks at every page as Googlebot, to catch spam shown only to search engines. New sites include a daily GitHub Actions check (opt in with the `VAGE_VERIFY_URL` variable). |
215
+ | Supply chain | New sites run `npm audit` in CI and get Dependabot updates. |
37
216
 
38
217
  ```bash
39
- npm create vagejs@latest my-site
40
- cd my-site
41
- npm install
42
- npm run dev
218
+ npx vage links # list external domains: approved or NEW
219
+ npx vage links approve github.com # approve one (asks first)
220
+ npx vage verify # compare the live site with your files
43
221
  ```
44
222
 
45
- ## Releasing
46
-
47
- See [docs/RELEASING.md](docs/RELEASING.md).
223
+ Also protect the accounts around the site: two-factor authentication on GitHub, npm, your host and your domain registrar; branch protection on `main`; and Google Search Console alerts for security issues.
48
224
 
49
- ## Development
225
+ ## Contributing
50
226
 
51
227
  ```bash
228
+ npm install
52
229
  npm run typecheck
53
230
  npm test # unit + integration (dev server, preview, build)
54
231
  npm run test:e2e # pack -> create -> npm install/ci -> build (needs npm registry or cache)
55
232
  ```
56
233
 
57
- ## Planned, not implemented
58
-
59
- Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths, sitemap indexes, MCP server. See `VAGE_JS_BUILD_SPEC.md`.
60
-
61
- ## Security
62
-
63
- Page content is sanitized, dev/preview servers serve only what a site needs, and Cloudflare deploys get security headers with a computed Content-Security-Policy. Report vulnerabilities privately: see [SECURITY.md](SECURITY.md).
234
+ Design decisions: [docs/DECISIONS.md](docs/DECISIONS.md). Provider rules and how they're monitored: [docs/GUIDELINES.md](docs/GUIDELINES.md). Releasing: [docs/RELEASING.md](docs/RELEASING.md).
64
235
 
65
236
  ## Brand assets
66
237
 
67
238
  - `assets/brand/vagejs-icon.svg`: the icon, as received (with C2PA content credentials).
68
- - `assets/brand/vagejs-logo.svg`: the wordmark with **Space Grotesk Bold embedded** (a 1.6 KB WOFF2 subset of the glyphs used), so it renders the same without the font installed. Space Grotesk is © 2020 The Space Grotesk Project Authors, under the SIL Open Font License 1.1 (`assets/brand/fonts/OFL.txt`). Editing the file invalidated its C2PA signature, so that block was removed.
239
+ - `assets/brand/vagejs-logo.svg` and `vagejs-logo-dark.svg`: the wordmark for light and dark backgrounds, with **Space Grotesk Bold embedded** (a 1.6 KB WOFF2 subset of the glyphs used), so it renders the same without the font installed. Space Grotesk is © 2020 The Space Grotesk Project Authors, under the SIL Open Font License 1.1 (`assets/brand/fonts/OFL.txt`). Editing the file invalidated its C2PA signature, so that block was removed. The dark variant only changes the text colors (`#F4F5FA` and `#A5B4FC`).
69
240
  - New sites get `favicon.svg`, `favicon.ico` (16/32/48) and `apple-touch-icon.png` (180) in `public/`.
70
241
 
71
242
  Regenerate (maintainer tools; need Chrome, and Python with `fonttools` and `brotli`):
@@ -18,6 +18,8 @@ export interface CheckResult {
18
18
  pagesChecked: number;
19
19
  findings: Finding[];
20
20
  }
21
+ /** Paths of frontmatter values that came out empty (null), e.g. "faq.items[3].Claude and other AI search?". */
22
+ export declare function emptyValues(value: unknown, at?: string): string[];
21
23
  /**
22
24
  * Static checks on the rendered site (nothing is written to disk).
23
25
  * Passing checks mean the output is technically sound; they do not guarantee
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { parse } from "node-html-parser";
4
4
  import { createBuildLoader } from "../core/loader.js";
5
5
  import { listedPages, renderSite } from "../core/site.js";
6
+ import { externalHost, hiddenReason, isEmptyLink, LINK_LOCK, linkTarget, outboundHosts, readLinkLock, spamHits } from "./integrity.js";
6
7
  const DOCS = {
7
8
  title: "https://developers.google.com/search/docs/appearance/title-link",
8
9
  description: "https://developers.google.com/search/docs/appearance/snippet",
@@ -13,7 +14,19 @@ const DOCS = {
13
14
  lang: "https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html",
14
15
  linkPurpose: "https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html",
15
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",
16
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
+ }
17
30
  /**
18
31
  * Static checks on the rendered site (nothing is written to disk).
19
32
  * Passing checks mean the output is technically sound; they do not guarantee
@@ -23,7 +36,7 @@ export async function check(root, options = {}) {
23
36
  root = path.resolve(root);
24
37
  const loader = await createBuildLoader(root);
25
38
  try {
26
- const model = await renderSite(root, loader.load);
39
+ const model = await renderSite(root, loader.load, { collectErrors: true });
27
40
  const findings = checkSite(root, model);
28
41
  const errors = findings.filter((f) => f.severity === "error").length;
29
42
  const warnings = findings.length - errors;
@@ -31,7 +44,7 @@ export async function check(root, options = {}) {
31
44
  ok: errors === 0 && (!options.strict || warnings === 0),
32
45
  errors,
33
46
  warnings,
34
- pagesChecked: model.pages.length + (model.notFound ? 1 : 0),
47
+ pagesChecked: model.pages.length + (model.notFound ? 1 : 0) + model.pageErrors.length,
35
48
  findings,
36
49
  };
37
50
  }
@@ -41,7 +54,13 @@ export async function check(root, options = {}) {
41
54
  }
42
55
  export function checkSite(root, model) {
43
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 });
44
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`);
45
64
  const drafts = new Set(model.draftRoutes);
46
65
  const pages = model.notFound ? [...model.pages, model.notFound] : model.pages;
47
66
  for (const page of pages) {
@@ -50,10 +69,31 @@ export function checkSite(root, model) {
50
69
  }
51
70
  findings.push(...checkDuplicates(listedPages(model)));
52
71
  findings.push(...checkRobots(model));
72
+ findings.push(...checkRedirectTargets(model, outputs));
73
+ findings.push(...checkOutboundLinks(root, model));
53
74
  return findings;
54
75
  }
55
76
  function checkPage(page, doc, model, outputs, drafts, add) {
56
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
+ }
57
97
  if (!doc.querySelector("html")?.getAttribute("lang")) {
58
98
  add({ rule: "html-lang", severity: "error", message: "<html> has no lang attribute.", docs: DOCS.lang });
59
99
  }
@@ -104,6 +144,7 @@ function checkPage(page, doc, model, outputs, drafts, add) {
104
144
  }
105
145
  for (const a of doc.querySelectorAll("a[href]")) {
106
146
  const href = a.getAttribute("href");
147
+ checkHiddenLink(a, href, model, add);
107
148
  if (!a.text.trim() && !a.getAttribute("aria-label") && !a.querySelector("img[alt]:not([alt=''])")) {
108
149
  add({ rule: "link-text", severity: "warning", message: `Link to "${href}" has no accessible text.`, docs: DOCS.linkPurpose });
109
150
  }
@@ -127,6 +168,11 @@ function checkPage(page, doc, model, outputs, drafts, add) {
127
168
  add({ rule: "link-trailing-slash", severity: "warning", message: `Link to "${href}" redirects; use "${target}/".` });
128
169
  }
129
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
+ }
130
176
  for (const [selector, attr] of [
131
177
  ["img[src]", "src"],
132
178
  ["script[src]", "src"],
@@ -253,6 +299,64 @@ function checkDuplicates(pages) {
253
299
  }
254
300
  return findings;
255
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
+ }
256
360
  function checkRobots(model) {
257
361
  const robots = model.files.get("robots.txt");
258
362
  if (!robots)
@@ -0,0 +1,34 @@
1
+ import type { Difference } from "./verify.js";
2
+ /**
3
+ * Can search engines and AI crawlers actually reach the live site? Checks what a site's
4
+ * own files can't show: CDN/firewall blocks and challenges, robots.txt rules, missing
5
+ * http→https and www redirects, and noindex headers. Probes send each crawler's
6
+ * documented user agent (or token), so blocks based on the real crawlers' IP
7
+ * addresses can't be detected this way.
8
+ */
9
+ export interface Crawler {
10
+ token: string;
11
+ provider: string;
12
+ purpose: "search" | "ai-search" | "ai-training" | "ai-user";
13
+ userAgent?: string;
14
+ source: string;
15
+ }
16
+ export declare const CRAWLERS: Crawler[];
17
+ /** The documented user agent (W.X.Y.Z filled in), or a minimal one carrying the documented token. */
18
+ export declare function probeUserAgent(c: Crawler): string;
19
+ /**
20
+ * Is the site root ("/") disallowed for this crawler? RFC 9309: the crawler follows the
21
+ * groups naming its token (merged), else the "*" groups; among rules matching "/", the
22
+ * longest pattern wins and Allow wins ties. Lines like Sitemap: don't end a group.
23
+ */
24
+ export declare function robotsBlocks(robots: string, token: string): boolean;
25
+ /** Does an X-Robots-Tag value say noindex for everyone, or for one of these crawlers? */
26
+ export declare function xRobotsNoindex(value: string, tokens: string[]): boolean;
27
+ type Fetcher = typeof fetch;
28
+ export declare function checkCrawlability(origin: string, options?: {
29
+ fetch?: Fetcher;
30
+ }): Promise<{
31
+ differences: Difference[];
32
+ crawlersChecked: number;
33
+ }>;
34
+ export {};
@@ -0,0 +1,142 @@
1
+ import { ruleData } from "../guidelines/pack.js";
2
+ export const CRAWLERS = ruleData("crawlers.probes").crawlers;
3
+ /** The documented user agent (W.X.Y.Z filled in), or a minimal one carrying the documented token. */
4
+ export function probeUserAgent(c) {
5
+ return c.userAgent ? c.userAgent.replace("W.X.Y.Z", "131.0.0.0") : `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ${c.token}/1.0)`;
6
+ }
7
+ /** A challenge or block page, recognized by its title (not by scripts like reCAPTCHA that normal pages load). */
8
+ const CHALLENGE_TITLE = /<title>\s*(Just a moment|Attention Required|Access denied|Checking your browser)/i;
9
+ /** robots.txt path pattern -> RegExp (RFC 9309: "*" wildcard, "$" end anchor). */
10
+ function patternRegex(pattern) {
11
+ const anchored = pattern.endsWith("$");
12
+ const body = (anchored ? pattern.slice(0, -1) : pattern).split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
13
+ return new RegExp(`^${body}${anchored ? "$" : ""}`);
14
+ }
15
+ /**
16
+ * Is the site root ("/") disallowed for this crawler? RFC 9309: the crawler follows the
17
+ * groups naming its token (merged), else the "*" groups; among rules matching "/", the
18
+ * longest pattern wins and Allow wins ties. Lines like Sitemap: don't end a group.
19
+ */
20
+ export function robotsBlocks(robots, token) {
21
+ const groups = [];
22
+ let current;
23
+ let collectingAgents = false;
24
+ for (const raw of robots.split(/\r?\n/)) {
25
+ const m = /^\s*([a-z-]+)\s*:\s*(.*?)\s*$/i.exec(raw.replace(/#.*/, ""));
26
+ if (!m)
27
+ continue;
28
+ const key = m[1].toLowerCase();
29
+ const value = m[2];
30
+ if (key === "user-agent") {
31
+ if (!current || !collectingAgents)
32
+ groups.push((current = { agents: [], rules: [] }));
33
+ // "Googlebot/2.1" names the Googlebot product token.
34
+ current.agents.push(value.toLowerCase().split("/")[0].trim());
35
+ collectingAgents = true;
36
+ }
37
+ else if (key === "allow" || key === "disallow") {
38
+ collectingAgents = false;
39
+ if (current && value)
40
+ current.rules.push({ allow: key === "allow", pattern: value });
41
+ }
42
+ }
43
+ const named = groups.filter((g) => g.agents.includes(token.toLowerCase()));
44
+ const rules = (named.length ? named : groups.filter((g) => g.agents.includes("*"))).flatMap((g) => g.rules);
45
+ let best;
46
+ for (const r of rules) {
47
+ if (!patternRegex(r.pattern).test("/"))
48
+ continue;
49
+ const length = r.pattern.length;
50
+ if (!best || length > best.length || (length === best.length && r.allow))
51
+ best = { allow: r.allow, length };
52
+ }
53
+ return best !== undefined && !best.allow;
54
+ }
55
+ /** Does an X-Robots-Tag value say noindex for everyone, or for one of these crawlers? */
56
+ export function xRobotsNoindex(value, tokens) {
57
+ const lower = tokens.map((t) => t.toLowerCase());
58
+ // "googlebot: noindex, nofollow" targets one bot; "noindex" targets all.
59
+ return value.split(/,(?=\s*[a-z-]+\s*:)/i).some((part) => {
60
+ const m = /^\s*([a-z-]+)\s*:\s*(.*)$/i.exec(part);
61
+ const target = m && !/^(unavailable_after|max-[a-z-]+)$/i.test(m[1]) ? m[1].toLowerCase() : undefined;
62
+ const directives = target ? m[2] : part;
63
+ return (!target || lower.includes(target)) && /\b(noindex|none)\b/i.test(directives);
64
+ });
65
+ }
66
+ export async function checkCrawlability(origin, options = {}) {
67
+ const doFetch = options.fetch ?? fetch;
68
+ const out = [];
69
+ const get = async (url, init = {}, maxChars = 8000) => {
70
+ try {
71
+ const res = await doFetch(url, { signal: AbortSignal.timeout(20_000), ...init });
72
+ // Read the body here too: a reset mid-download is a failed request, not a crash.
73
+ const body = res.ok ? (await res.text()).slice(0, maxChars) : "";
74
+ return { status: res.status, ok: res.ok, headers: res.headers, body };
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ };
80
+ const blockedReason = (res) => !res ? "no response" : res.headers.get("cf-mitigated") ? `blocked by the CDN (cf-mitigated: ${res.headers.get("cf-mitigated")})` : !res.ok ? `HTTP ${res.status}` : CHALLENGE_TITLE.test(res.body) ? "a challenge page instead of the site" : undefined;
81
+ // 1. Crawlers reach the home page. Only blocks that a normal browser doesn't get count.
82
+ const browser = await get(`${origin}/`, { headers: { "user-agent": "Mozilla/5.0 (compatible; vagejs-verify) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", accept: "text/html" } });
83
+ const browserBlocked = blockedReason(browser);
84
+ if (browserBlocked) {
85
+ out.push({ route: "/", severity: "error", kind: "crawler-blocked", detail: `Even a normal browser gets ${browserBlocked}, so crawlers can't reach the site either.` });
86
+ }
87
+ else {
88
+ for (const c of CRAWLERS) {
89
+ const why = blockedReason(await get(`${origin}/`, { headers: { "user-agent": probeUserAgent(c), accept: "text/html" } }));
90
+ if (!why)
91
+ continue;
92
+ out.push({
93
+ route: "/",
94
+ severity: "warning",
95
+ kind: "crawler-blocked",
96
+ detail: `${c.token} (${c.provider}, ${c.purpose}) gets ${why}. ${c.purpose === "search"
97
+ ? "If your host blocks fake search bots, this probe (which can't come from the real crawler's addresses) may be what's blocked; confirm in Google Search Console or Bing Webmaster Tools."
98
+ : "If you didn't block it on purpose, check your host's bot or AI-crawler settings."}`,
99
+ });
100
+ }
101
+ }
102
+ // 2. robots.txt
103
+ // Read robots.txt in full (Google processes up to 500 KiB), not just the first few KB.
104
+ const robotsRes = await get(`${origin}/robots.txt`, {}, 512 * 1024);
105
+ if (robotsRes?.ok) {
106
+ for (const c of CRAWLERS.filter((x) => x.purpose !== "ai-user")) {
107
+ if (robotsBlocks(robotsRes.body, c.token)) {
108
+ out.push({
109
+ route: "/robots.txt",
110
+ severity: c.purpose === "search" ? "error" : "warning",
111
+ kind: "robots-blocked",
112
+ detail: `robots.txt disallows ${c.token} (${c.provider}, ${c.purpose}) from the whole site.${c.purpose === "ai-training" ? " Fine if that's your choice (seo.robots.aiTraining)." : ""}`,
113
+ });
114
+ }
115
+ }
116
+ }
117
+ // 3. noindex sent as a header hides the site even when the HTML says nothing.
118
+ const home = await get(`${origin}/`, { redirect: "manual" });
119
+ const xRobots = home?.headers.get("x-robots-tag");
120
+ if (xRobots && xRobotsNoindex(xRobots, ["googlebot", "bingbot"])) {
121
+ out.push({ route: "/", severity: "error", kind: "noindex-header", detail: `The server sends "X-Robots-Tag: ${xRobots}", which keeps the site out of search results.` });
122
+ }
123
+ // 4. One address per page: http should redirect to https, and www/non-www to the site's host.
124
+ // Skipped for local addresses (ports, IPs, localhost), where there's no public twin.
125
+ const url = new URL(origin);
126
+ const publicHost = !url.port && !/^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/i.test(url.hostname) && url.hostname.includes(".");
127
+ if (publicHost && url.protocol === "https:") {
128
+ const plain = await get(`http://${url.host}/`, { redirect: "manual" });
129
+ const to = plain?.headers.get("location") ?? "";
130
+ if (plain && !(plain.status >= 300 && plain.status < 400 && to.startsWith("https://"))) {
131
+ out.push({ route: "/", severity: "warning", kind: "redirect-missing", detail: `http://${url.host}/ answers ${plain.status} instead of redirecting to https. Turn on "Always use HTTPS" (or equivalent) at your host.` });
132
+ }
133
+ }
134
+ if (publicHost) {
135
+ const other = url.hostname.startsWith("www.") ? url.hostname.slice(4) : `www.${url.hostname}`;
136
+ const twin = await get(`${url.protocol}//${other}/`, { redirect: "manual" });
137
+ if (twin && twin.status === 200) {
138
+ out.push({ route: "/", severity: "warning", kind: "duplicate-host", detail: `${other} serves the site too instead of redirecting to ${url.hostname}. Add a redirect at your host so search engines see one address.` });
139
+ }
140
+ }
141
+ return { differences: out, crawlersChecked: browserBlocked ? 0 : CRAWLERS.length };
142
+ }