vagejs 0.1.0 → 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 (64) hide show
  1. package/README.md +190 -25
  2. package/dist/checks/check.d.ts +2 -0
  3. package/dist/checks/check.js +106 -2
  4. package/dist/checks/integrity.d.ts +36 -0
  5. package/dist/checks/integrity.js +145 -0
  6. package/dist/checks/links.d.ts +18 -0
  7. package/dist/checks/links.js +47 -0
  8. package/dist/checks/verify.d.ts +35 -0
  9. package/dist/checks/verify.js +167 -0
  10. package/dist/cli/index.js +116 -10
  11. package/dist/cli/info.d.ts +35 -0
  12. package/dist/cli/info.js +49 -4
  13. package/dist/content/images.d.ts +23 -0
  14. package/dist/content/images.js +96 -0
  15. package/dist/content/markdown.d.ts +2 -0
  16. package/dist/content/markdown.js +6 -1
  17. package/dist/content/sanitize.d.ts +11 -2
  18. package/dist/content/sanitize.js +49 -9
  19. package/dist/core/clone.d.ts +6 -0
  20. package/dist/core/clone.js +16 -0
  21. package/dist/core/config.d.ts +9 -0
  22. package/dist/core/config.js +21 -4
  23. package/dist/core/errors.d.ts +2 -0
  24. package/dist/core/errors.js +3 -0
  25. package/dist/core/html.d.ts +22 -0
  26. package/dist/core/html.js +31 -0
  27. package/dist/core/pages.d.ts +40 -0
  28. package/dist/core/pages.js +93 -0
  29. package/dist/core/render.d.ts +17 -1
  30. package/dist/core/render.js +51 -9
  31. package/dist/core/site.d.ts +12 -1
  32. package/dist/core/site.js +36 -13
  33. package/dist/core/validate.d.ts +2 -0
  34. package/dist/core/validate.js +6 -0
  35. package/dist/deploy/cloudflare-pages.js +3 -1
  36. package/dist/deploy/cloudflare-workers.d.ts +59 -0
  37. package/dist/deploy/cloudflare-workers.js +223 -0
  38. package/dist/deploy/headers.js +1 -1
  39. package/dist/deploy/publish.d.ts +1 -1
  40. package/dist/deploy/publish.js +3 -3
  41. package/dist/deploy/redirects.js +1 -1
  42. package/dist/deploy/secrets.js +1 -1
  43. package/dist/dev/server.js +3 -1
  44. package/dist/guidelines/rule-pack.json +1 -1
  45. package/dist/index.d.ts +4 -1
  46. package/dist/index.js +4 -1
  47. package/dist/integrations/api-keys.d.ts +1 -1
  48. package/dist/integrations/api-keys.js +1 -1
  49. package/dist/integrations/google-auth.js +1 -1
  50. package/dist/integrations/tags.d.ts +1 -1
  51. package/dist/integrations/tags.js +1 -1
  52. package/dist/seo/head.js +1 -1
  53. package/package.json +1 -1
  54. package/templates/starter/.github/dependabot.yml +11 -0
  55. package/templates/starter/.github/workflows/ci.yml +2 -0
  56. package/templates/starter/.github/workflows/verify.yml +32 -0
  57. package/templates/starter/AGENTS.md +36 -15
  58. package/templates/starter/links.lock.json +4 -0
  59. package/templates/starter/package.json +1 -1
  60. package/templates/starter/src/layouts/blog.ts +13 -0
  61. package/templates/starter/src/layouts/default.ts +8 -7
  62. package/templates/starter/src/pages/blog/index.md +7 -0
  63. package/templates/starter/src/pages/index.md +2 -2
  64. package/templates/starter/vage.config.ts +5 -1
package/README.md CHANGED
@@ -1,12 +1,172 @@
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.
23
+
24
+ ## Quick start
25
+
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.
72
+
73
+ ### Write a layout
74
+
75
+ 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:
76
+
77
+ ```ts
78
+ import { collection, html, raw, z, type Layout } from "vagejs";
79
+
80
+ // Optional: fields this layout accepts. Pages using it are checked strictly, so typos fail the build.
81
+ export const frontmatter = z.object({ intro: z.string().optional() });
82
+
83
+ const layout: Layout = ({ content, page, site, pages }) => html`<!doctype html>
84
+ <html lang="${site.lang}">
85
+ <head><title>${page.title}</title></head>
86
+ <body>
87
+ <main>${raw(content)}</main>
88
+ <h2>Latest posts</h2>
89
+ <ul>${collection(pages, { under: "/blog/", sort: "date", limit: 5 }).map((p) => html`<li><a href="${p.route}">${p.title}</a></li>`)}</ul>
90
+ </body>
91
+ </html>`;
92
+
93
+ export default layout;
94
+ ```
95
+
96
+ ### Everyday commands
97
+
98
+ | Command | What it does |
99
+ | --- | --- |
100
+ | `npm run dev` | Dev server with live reload |
101
+ | `npm run check` | Links, SEO, structured data and basic accessibility; writes nothing |
102
+ | `npm run build` | Static site in `dist/`, plus `robots.txt`, `sitemap.xml`, `llms.txt` |
103
+ | `npm run audit` | Page-weight budgets and automated accessibility (`-- --lighthouse` adds Lighthouse) |
104
+ | `npm run preview` | Serves `dist/` locally, the way a static host would |
105
+
106
+ Add `--json` to `npx vage build`, `check` or `audit` for machine-readable output. Failures exit with code 1.
107
+
108
+ ### Configure the site
109
+
110
+ `vage.config.ts`:
111
+
112
+ ```ts
113
+ import { defineConfig } from "vagejs";
114
+
115
+ export default defineConfig({
116
+ site: "https://example.com", // your real domain (used for canonical URLs and the sitemap)
117
+ title: "My Website",
118
+ description: "What this site is about.",
119
+ seo: {
120
+ robots: { aiTraining: "disallow" }, // optional: block AI training crawlers, keep search
121
+ llmsTxt: { full: false },
122
+ },
123
+ redirects: { "/old-page/": "/new-page/" },
124
+ });
125
+ ```
6
126
 
7
- An AI-native, static-first web framework. **Pre-release (0.x).**
127
+ Unknown keys are an error, so typos don't silently do nothing. `npx vage info --json` prints the full schema.
8
128
 
9
- ## What works today
129
+ ### Deploy
130
+
131
+ ```bash
132
+ npx vage deploy:github --remote https://github.com/you/site.git # check, build, safe commit + push (asks first)
133
+ npx vage deploy # GitHub Pages via Actions
134
+ npx vage deploy --target cloudflare-workers --project my-site # Cloudflare Workers: new version, no downtime
135
+ npx vage deploy --target cloudflare-pages --project my-site # older Cloudflare Pages projects
136
+ ```
137
+
138
+ Every deploy shows its plan and asks for confirmation (`--dry-run` to preview, `--yes` to skip). It reports success only after the host confirms.
139
+
140
+ 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.
141
+
142
+ ### Analytics, Search Console and audits of live pages
143
+
144
+ 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`.
145
+
146
+ ## Updating
147
+
148
+ 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.
149
+
150
+ ```bash
151
+ npm outdated vagejs # see what's available
152
+ npm update vagejs # latest patch in your range (with ^0.2.0: any 0.2.x)
153
+ npm install vagejs@latest # move to the newest version, including a new minor
154
+ npx vage --version # confirm
155
+ npx vage check && npx vage build
156
+ ```
157
+
158
+ 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`.
159
+
160
+ ## Use with AI coding agents
161
+
162
+ Every site includes `AGENTS.md` (conventions, recipes, definition of done) and `CLAUDE.md` (imports `AGENTS.md`). Agents can discover the API instead of guessing:
163
+
164
+ ```bash
165
+ npx vage info --json # commands, options, config and frontmatter JSON Schemas
166
+ npx vage check --json # findings with file, rule and fix hints
167
+ ```
168
+
169
+ ## Command reference
10
170
 
11
171
  ```bash
12
172
  vage create <dir> [--vage-spec <spec>] [--json] # scaffold a Markdown site
@@ -16,8 +176,9 @@ vage check [--root .] [--json] [--strict] # SEO, links, JSON-LD, basic a
16
176
  vage audit [--root .] [--lighthouse] [--url U] [--strategy mobile|desktop|both] [--strict] [--json]
17
177
  # budgets + axe a11y; optional Lighthouse; PageSpeed/CrUX for deployed URLs
18
178
  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)
179
+ vage deploy:github [--remote URL] [--branch B] [-m MSG] [--dry-run] [--yes] [--json] # check, build, safe commit + push (asks first)
20
180
  vage deploy [--remote URL] [--no-wait] [--dry-run] [--yes] # GitHub Pages via Actions; confirms with GitHub
181
+ vage deploy --target cloudflare-workers [--project NAME] # new Worker version at 100%; custom domains stay up
21
182
  vage deploy --target cloudflare-pages --project NAME [--branch B] # Wrangler upload; preview URL per branch
22
183
  vage indexnow --changed-since REF | --urls … | --all # IndexNow URL notification (asks first)
23
184
  vage connect gsc|ga4 / vage disconnect google # read-only Google OAuth (PKCE, loopback); token outside the repo
@@ -27,45 +188,49 @@ vage guidelines [--json] # rule pack: provider facts in
27
188
  vage info [--json] # commands + config/frontmatter JSON Schemas for agents
28
189
  ```
29
190
 
30
- Generated sites include `AGENTS.md` (conventions, recipes, definition of done) and `CLAUDE.md` (imports AGENTS.md).
191
+ ## Planned, not implemented
192
+
193
+ Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths, sitemap indexes, MCP server, image resizing and WebP/AVIF conversion.
31
194
 
32
- Failures exit with code 1. With `--json`, output is `{ "ok": true, ... }` or `{ "ok": false, "error": "..." }`.
195
+ ## Security
196
+
197
+ 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).
33
198
 
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.
199
+ ### Protection against spam injection
35
200
 
36
- ## Getting started
201
+ 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:
202
+
203
+ | Protection | What it does |
204
+ | --- | --- |
205
+ | 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. |
206
+ | 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. |
207
+ | Spam words | `vage check` warns about common injected-spam terms (casino, pharmacy, payday loans…). |
208
+ | 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). |
209
+ | Supply chain | New sites run `npm audit` in CI and get Dependabot updates. |
37
210
 
38
211
  ```bash
39
- npm create vagejs@latest my-site
40
- cd my-site
41
- npm install
42
- npm run dev
212
+ npx vage links # list external domains: approved or NEW
213
+ npx vage links approve github.com # approve one (asks first)
214
+ npx vage verify # compare the live site with your files
43
215
  ```
44
216
 
45
- ## Releasing
46
-
47
- See [docs/RELEASING.md](docs/RELEASING.md).
217
+ 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
218
 
49
- ## Development
219
+ ## Contributing
50
220
 
51
221
  ```bash
222
+ npm install
52
223
  npm run typecheck
53
224
  npm test # unit + integration (dev server, preview, build)
54
225
  npm run test:e2e # pack -> create -> npm install/ci -> build (needs npm registry or cache)
55
226
  ```
56
227
 
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).
228
+ 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
229
 
65
230
  ## Brand assets
66
231
 
67
232
  - `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.
233
+ - `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
234
  - New sites get `favicon.svg`, `favicon.ico` (16/32/48) and `apple-touch-icon.png` (180) in `public/`.
70
235
 
71
236
  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,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 };