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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vagejs.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,245 @@
1
- # Temporary Holding Version
1
+ <p align="center">
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>
6
+ </p>
2
7
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ```
126
+
127
+ Unknown keys are an error, so typos don't silently do nothing. `npx vage info --json` prints the full schema.
128
+
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
170
+
171
+ ```bash
172
+ vage create <dir> [--vage-spec <spec>] [--json] # scaffold a Markdown site
173
+ vage dev [--root .] [--port N] [--host] # dev server with live reload
174
+ vage build [--root .] [--json] # static build into dist/ (+ robots.txt, sitemap.xml, llms.txt)
175
+ vage check [--root .] [--json] [--strict] # SEO, links, JSON-LD, basic a11y; writes nothing
176
+ vage audit [--root .] [--lighthouse] [--url U] [--strategy mobile|desktop|both] [--strict] [--json]
177
+ # budgets + axe a11y; optional Lighthouse; PageSpeed/CrUX for deployed URLs
178
+ vage preview [--root .] [--port N] [--host] # serve dist/ locally
179
+ vage deploy:github [--remote URL] [--branch B] [-m MSG] [--dry-run] [--yes] [--json] # check, build, safe commit + push (asks first)
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
182
+ vage deploy --target cloudflare-pages --project NAME [--branch B] # Wrangler upload; preview URL per branch
183
+ vage indexnow --changed-since REF | --urls … | --all # IndexNow URL notification (asks first)
184
+ vage connect gsc|ga4 / vage disconnect google # read-only Google OAuth (PKCE, loopback); token outside the repo
185
+ vage connect pagespeed|crux / vage disconnect … # save your own Google API key for audit --url (outside the repo)
186
+ vage report search|analytics [--days N] [--json] # Search Console top queries/pages; GA4 top pages
187
+ vage guidelines [--json] # rule pack: provider facts in use, sources, changelog
188
+ vage info [--json] # commands + config/frontmatter JSON Schemas for agents
189
+ ```
190
+
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.
194
+
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).
198
+
199
+ ### Protection against spam injection
200
+
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. |
210
+
211
+ ```bash
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
215
+ ```
216
+
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.
218
+
219
+ ## Contributing
220
+
221
+ ```bash
222
+ npm install
223
+ npm run typecheck
224
+ npm test # unit + integration (dev server, preview, build)
225
+ npm run test:e2e # pack -> create -> npm install/ci -> build (needs npm registry or cache)
226
+ ```
227
+
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).
229
+
230
+ ## Brand assets
231
+
232
+ - `assets/brand/vagejs-icon.svg`: the icon, as received (with C2PA content credentials).
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`).
234
+ - New sites get `favicon.svg`, `favicon.ico` (16/32/48) and `apple-touch-icon.png` (180) in `public/`.
235
+
236
+ Regenerate (maintainer tools; need Chrome, and Python with `fonttools` and `brotli`):
237
+
238
+ ```bash
239
+ node scripts/brand/favicons.mjs templates/starter/public/favicon.svg templates/starter/public
240
+ python -I scripts/brand/embed-logo-font.py "SpaceGrotesk[wght].ttf" <original-logo.svg> assets/brand/vagejs-logo.svg
241
+ ```
242
+
243
+ ## License
244
+
245
+ MIT © 2026 vagejs.com
@@ -0,0 +1,13 @@
1
+ import type { Finding } from "../checks/check.js";
2
+ export interface A11yPageResult {
3
+ route: string;
4
+ findings: Finding[];
5
+ /** Rules axe could not decide without a real browser (e.g. color contrast). */
6
+ needsReview: string[];
7
+ }
8
+ /**
9
+ * Runs axe-core against static HTML in jsdom. No page scripts run and nothing
10
+ * is fetched, so layout-dependent rules (color contrast, target size) come back
11
+ * as "needs review"; use Lighthouse (`vage audit --lighthouse`) for those.
12
+ */
13
+ export declare function auditAccessibility(route: string, html: string): Promise<A11yPageResult>;
@@ -0,0 +1,33 @@
1
+ import { createRequire } from "node:module";
2
+ import fs from "node:fs";
3
+ import { JSDOM, VirtualConsole } from "jsdom";
4
+ const axeSource = fs.readFileSync(createRequire(import.meta.url).resolve("axe-core/axe.min.js"), "utf8");
5
+ /**
6
+ * Runs axe-core against static HTML in jsdom. No page scripts run and nothing
7
+ * is fetched, so layout-dependent rules (color contrast, target size) come back
8
+ * as "needs review"; use Lighthouse (`vage audit --lighthouse`) for those.
9
+ */
10
+ export async function auditAccessibility(route, html) {
11
+ // A detached console keeps jsdom's "not implemented" notices (e.g. canvas, probed by axe) out of the output.
12
+ const dom = new JSDOM(html, { runScripts: "outside-only", pretendToBeVisual: true, virtualConsole: new VirtualConsole() });
13
+ try {
14
+ const window = dom.window;
15
+ window.eval(axeSource);
16
+ const results = await window.axe.run(window.document, { resultTypes: ["violations", "incomplete"] });
17
+ const findings = results.violations.map((v) => ({
18
+ rule: `a11y:${v.id}`,
19
+ // critical/serious block users; moderate/minor still matter but are warnings.
20
+ severity: v.impact === "critical" || v.impact === "serious" ? "error" : "warning",
21
+ page: route,
22
+ message: `${v.help} (${v.nodes.length} element${v.nodes.length === 1 ? "" : "s"}: ${v.nodes
23
+ .slice(0, 3)
24
+ .map((n) => n.target.join(" "))
25
+ .join(", ")})`,
26
+ docs: v.helpUrl,
27
+ }));
28
+ return { route, findings, needsReview: results.incomplete.map((r) => r.id) };
29
+ }
30
+ finally {
31
+ dom.window.close();
32
+ }
33
+ }
@@ -0,0 +1,31 @@
1
+ import type { Finding } from "../checks/check.js";
2
+ import { type ApiKeyName } from "../integrations/api-keys.js";
3
+ import { type PageWeight } from "./budgets.js";
4
+ import { type FieldReport, type LabReport, type RemoteOptions, type Strategy } from "./remote.js";
5
+ export interface AuditOptions {
6
+ /** Fail on warnings too (budgets, lab scores, API failures). */
7
+ strict?: boolean;
8
+ /** Run Lighthouse locally (also enabled by performance.lighthouse.enabled). */
9
+ lighthouse?: boolean;
10
+ /** Audit a deployed URL with PageSpeed Insights + CrUX instead of the local build. */
11
+ url?: string;
12
+ strategies?: Strategy[];
13
+ env?: Record<string, string | undefined>;
14
+ remote?: RemoteOptions;
15
+ /** Asks the user for their own key when none is configured (interactive CLI only). */
16
+ askForKey?: (name: ApiKeyName) => Promise<string | undefined>;
17
+ }
18
+ export interface AuditResult {
19
+ ok: boolean;
20
+ errors: number;
21
+ warnings: number;
22
+ target: string;
23
+ findings: Finding[];
24
+ pages: PageWeight[];
25
+ /** axe rules that need a real browser or manual review. */
26
+ needsReview: string[];
27
+ lab: LabReport[];
28
+ field: FieldReport[];
29
+ notes: string[];
30
+ }
31
+ export declare function audit(root: string, options?: AuditOptions): Promise<AuditResult>;
@@ -0,0 +1,143 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { buildSite } from "../core/build.js";
4
+ import { loadProjectConfig } from "../core/project-config.js";
5
+ import { missingKeyMessage, resolveApiKey } from "../integrations/api-keys.js";
6
+ import { VageError } from "../core/errors.js";
7
+ import { startPreviewServer } from "../dev/preview.js";
8
+ import { auditAccessibility } from "./a11y.js";
9
+ import { budgetFindings, measurePages } from "./budgets.js";
10
+ import { runLighthouse } from "./lighthouse.js";
11
+ import { cruxField, pageSpeed } from "./remote.js";
12
+ import { LAB_VS_FIELD, VITALS } from "./vitals.js";
13
+ export async function audit(root, options = {}) {
14
+ root = path.resolve(root);
15
+ const strategies = options.strategies ?? ["mobile", "desktop"];
16
+ const partial = options.url ? await auditUrl(root, options.url, strategies, options) : await auditLocal(root, strategies, options);
17
+ const errors = partial.findings.filter((f) => f.severity === "error").length;
18
+ const warnings = partial.findings.length - errors;
19
+ if (partial.lab.length || partial.field.length)
20
+ partial.notes.push(LAB_VS_FIELD);
21
+ return { ...partial, ok: errors === 0 && (!options.strict || warnings === 0), errors, warnings };
22
+ }
23
+ async function auditLocal(root, strategies, options) {
24
+ const { result, model } = await buildSite(root);
25
+ const site = model.site;
26
+ const pages = model.notFound ? [...model.pages, model.notFound] : model.pages;
27
+ const weights = measurePages(result.outDir, pages, site.site);
28
+ const findings = budgetFindings(weights, site.performance.budgets);
29
+ const notes = ["Sizes are uncompressed bytes of local files; transfer size depends on your host's compression."];
30
+ const needsReview = new Map();
31
+ for (const page of pages) {
32
+ const html = fs.readFileSync(path.join(result.outDir, page.outFile), "utf8");
33
+ const a11y = await auditAccessibility(page.route, html);
34
+ findings.push(...a11y.findings.map((f) => ({ ...f, source: page.source })));
35
+ for (const rule of a11y.needsReview)
36
+ needsReview.set(rule, (needsReview.get(rule) ?? 0) + 1);
37
+ }
38
+ if (needsReview.size) {
39
+ const list = [...needsReview].map(([rule, n]) => `${rule} (${n} page${n === 1 ? "" : "s"})`).join(", ");
40
+ notes.push(`Automated checks could not decide: ${list}. Review manually or run with --lighthouse (real browser).`);
41
+ }
42
+ for (const w of weights.filter((w) => w.hasJavaScript)) {
43
+ notes.push(`${w.route} ships client-side JavaScript (${(w.bytes.javascript / 1024).toFixed(1)} KB local). Static pages usually need none.`);
44
+ }
45
+ const lab = [];
46
+ if (options.lighthouse || site.performance.lighthouse.enabled) {
47
+ const routes = site.performance.lighthouse.pages;
48
+ const known = new Set(model.pages.map((p) => p.route));
49
+ const unknown = routes.filter((r) => !known.has(r));
50
+ if (unknown.length)
51
+ throw new VageError(`performance.lighthouse.pages lists routes that are not published: ${unknown.join(", ")}`);
52
+ const server = await startPreviewServer(root, {});
53
+ try {
54
+ const base = server.resolvedUrls.local[0].replace(/\/$/, "");
55
+ lab.push(...(await runLighthouse(root, routes.map((r) => base + r), strategies)));
56
+ }
57
+ finally {
58
+ await server.close();
59
+ }
60
+ notes.push("Local Lighthouse runs on your machine; scores differ from PageSpeed Insights and from real users.");
61
+ findings.push(...scoreFindings(lab, site));
62
+ }
63
+ return { target: result.outDir, findings, pages: weights, needsReview: [...needsReview.keys()], lab, field: [], notes };
64
+ }
65
+ async function auditUrl(root, url, strategies, options) {
66
+ if (!/^https?:\/\//.test(url))
67
+ throw new VageError(`--url must be an absolute http(s) URL, got "${url}".`);
68
+ const env = options.env ?? process.env;
69
+ const site = await loadProjectConfig(path.resolve(root));
70
+ const findings = [];
71
+ const notes = [];
72
+ const lab = [];
73
+ const field = [];
74
+ const failure = (rule, err) => findings.push({ rule, severity: options.strict ? "error" : "warning", message: err.message });
75
+ // Always the user's own keys: never a shared or built-in key, and no keyless fallback.
76
+ const keyFor = async (name) => resolveApiKey(name, env) ?? (await options.askForKey?.(name));
77
+ const psiKey = await keyFor("pagespeed");
78
+ if (!psiKey) {
79
+ findings.push({ rule: "pagespeed", severity: "error", message: missingKeyMessage("pagespeed") });
80
+ }
81
+ else {
82
+ for (const strategy of strategies) {
83
+ try {
84
+ lab.push(await pageSpeed(url, strategy, psiKey, options.remote));
85
+ }
86
+ catch (err) {
87
+ failure("pagespeed", err);
88
+ }
89
+ }
90
+ }
91
+ findings.push(...scoreFindings(lab, site));
92
+ // One Google Cloud key can enable both APIs, so the PageSpeed key is tried for CrUX too.
93
+ const cruxKey = resolveApiKey("crux", env) ?? psiKey ?? (await options.askForKey?.("crux"));
94
+ if (!cruxKey) {
95
+ notes.push(`No field data. ${missingKeyMessage("crux")}`);
96
+ }
97
+ else {
98
+ for (const strategy of strategies) {
99
+ try {
100
+ const report = await cruxField(url, strategy, cruxKey, options.remote);
101
+ if (!report) {
102
+ notes.push(`No CrUX field data for ${url} or its origin (${strategy}); the site may not have enough Chrome traffic.`);
103
+ continue;
104
+ }
105
+ field.push(report);
106
+ for (const [name, m] of Object.entries(report.metrics)) {
107
+ if (m.rating !== "good") {
108
+ const t = VITALS[name];
109
+ findings.push({
110
+ rule: "field-vitals",
111
+ severity: "warning",
112
+ message: `${t.label} p75 is ${m.p75}${t.unit} (${m.rating}; good is ≤ ${t.good}${t.unit}) for ${report.formFactor} (${report.scope}-level, real users).`,
113
+ docs: "https://web.dev/articles/vitals",
114
+ });
115
+ }
116
+ }
117
+ }
118
+ catch (err) {
119
+ failure("crux", err);
120
+ }
121
+ }
122
+ }
123
+ return { target: url, findings, pages: [], needsReview: [], lab, field, notes };
124
+ }
125
+ function scoreFindings(lab, site) {
126
+ const b = site.performance.budgets;
127
+ const budgets = { performance: b.performance, accessibility: b.accessibility, seo: b.seo, "best-practices": b.bestPractices };
128
+ const findings = [];
129
+ for (const r of lab) {
130
+ for (const [category, min] of Object.entries(budgets)) {
131
+ const score = r.scores[category];
132
+ if (score !== null && score < min) {
133
+ findings.push({
134
+ rule: "lab-score",
135
+ severity: "warning",
136
+ page: r.url,
137
+ message: `${category} ${score} < budget ${min} (${r.strategy}, ${r.source}, lab).`,
138
+ });
139
+ }
140
+ }
141
+ }
142
+ return findings;
143
+ }
@@ -0,0 +1,22 @@
1
+ import type { SiteConfig } from "../core/config.js";
2
+ import type { Finding } from "../checks/check.js";
3
+ export type ResourceKind = "html" | "javascript" | "css" | "image" | "font";
4
+ export interface PageWeight {
5
+ route: string;
6
+ /** Uncompressed bytes of local files by kind. Transfer size depends on the host's compression. */
7
+ bytes: Record<ResourceKind, number>;
8
+ total: number;
9
+ /** Requests including the HTML document and external resources. */
10
+ requests: number;
11
+ /** Resources on other origins: counted as requests, size not measured. */
12
+ external: string[];
13
+ /** True when the page ships executable JavaScript. */
14
+ hasJavaScript: boolean;
15
+ }
16
+ /** Measures each built page and the local resources it references. */
17
+ export declare function measurePages(outDir: string, pages: {
18
+ route: string;
19
+ outFile: string;
20
+ }[], site?: string): PageWeight[];
21
+ export declare function budgetFindings(weights: PageWeight[], budgets: SiteConfig["performance"]["budgets"]): Finding[];
22
+ export declare function kbText(bytes: number): string;
@@ -0,0 +1,106 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse } from "node-html-parser";
4
+ const FONT = /\.(woff2?|ttf|otf|eot)$/i;
5
+ const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg|ico)$/i;
6
+ /** Measures each built page and the local resources it references. */
7
+ export function measurePages(outDir, pages, site) {
8
+ return pages.map(({ route, outFile }) => {
9
+ const html = fs.readFileSync(path.join(outDir, outFile), "utf8");
10
+ const doc = parse(html);
11
+ const weight = {
12
+ route,
13
+ bytes: { html: Buffer.byteLength(html), javascript: 0, css: 0, image: 0, font: 0 },
14
+ total: 0,
15
+ requests: 1,
16
+ external: [],
17
+ hasJavaScript: false,
18
+ };
19
+ const seen = new Set();
20
+ const add = (ref, kind, base) => {
21
+ if (!ref || ref.startsWith("data:"))
22
+ return;
23
+ const url = new URL(ref, `http://local.invalid${base}`);
24
+ const key = url.href;
25
+ if (seen.has(key))
26
+ return;
27
+ seen.add(key);
28
+ weight.requests++;
29
+ const local = url.host === "local.invalid" || (site !== undefined && url.origin === site);
30
+ if (!local)
31
+ return weight.external.push(url.href);
32
+ let rel;
33
+ try {
34
+ rel = decodeURIComponent(url.pathname);
35
+ }
36
+ catch {
37
+ return; // malformed URLs are reported by `vage check`
38
+ }
39
+ const file = path.resolve(outDir, `.${rel}`);
40
+ if (!file.startsWith(outDir + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile())
41
+ return; // missing assets are reported by `vage check`
42
+ weight.bytes[kind] += fs.statSync(file).size;
43
+ if (kind === "css") {
44
+ // Fonts and images referenced from stylesheets load with the page too.
45
+ for (const m of fs.readFileSync(file, "utf8").matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)) {
46
+ const inner = m[1];
47
+ if (FONT.test(inner))
48
+ add(new URL(inner, url).pathname, "font", "/");
49
+ else if (IMAGE.test(inner))
50
+ add(new URL(inner, url).pathname, "image", "/");
51
+ }
52
+ }
53
+ };
54
+ for (const s of doc.querySelectorAll("script")) {
55
+ const type = s.getAttribute("type");
56
+ if (type && !/^(module|text\/javascript|application\/javascript)$/i.test(type))
57
+ continue; // e.g. JSON-LD
58
+ weight.hasJavaScript = true;
59
+ if (s.getAttribute("src"))
60
+ add(s.getAttribute("src"), "javascript", route);
61
+ }
62
+ for (const l of doc.querySelectorAll('link[rel="stylesheet"]'))
63
+ add(l.getAttribute("href"), "css", route);
64
+ for (const l of doc.querySelectorAll('link[rel="preload"][as="font"]'))
65
+ add(l.getAttribute("href"), "font", route);
66
+ for (const img of doc.querySelectorAll("img"))
67
+ add(img.getAttribute("src"), "image", route);
68
+ weight.total = Object.values(weight.bytes).reduce((a, b) => a + b, 0);
69
+ return weight;
70
+ });
71
+ }
72
+ const BUDGET_KEYS = [
73
+ ["javascriptKb", "javascript"],
74
+ ["cssKb", "css"],
75
+ ["imageKb", "image"],
76
+ ["fontKb", "font"],
77
+ ["totalKb", "total"],
78
+ ];
79
+ export function budgetFindings(weights, budgets) {
80
+ const findings = [];
81
+ for (const w of weights) {
82
+ for (const [key, kind] of BUDGET_KEYS) {
83
+ const bytes = kind === "total" ? w.total : w.bytes[kind];
84
+ if (bytes > budgets[key] * 1024) {
85
+ findings.push({
86
+ rule: "budget",
87
+ severity: "warning",
88
+ page: w.route,
89
+ message: `${kind} is ${kbText(bytes)}, over the ${budgets[key]} KB budget (performance.budgets.${key}).`,
90
+ });
91
+ }
92
+ }
93
+ if (w.requests > budgets.requests) {
94
+ findings.push({
95
+ rule: "budget",
96
+ severity: "warning",
97
+ page: w.route,
98
+ message: `${w.requests} requests, over the budget of ${budgets.requests} (performance.budgets.requests).`,
99
+ });
100
+ }
101
+ }
102
+ return findings;
103
+ }
104
+ export function kbText(bytes) {
105
+ return `${(bytes / 1024).toFixed(1)} KB`;
106
+ }
@@ -0,0 +1,3 @@
1
+ import { type LabReport, type Strategy } from "./remote.js";
2
+ /** Runs Lighthouse (lab) against already-served URLs. */
3
+ export declare function runLighthouse(root: string, urls: string[], strategies: Strategy[]): Promise<LabReport[]>;