vagejs 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +79 -2
- package/dist/audit/a11y.d.ts +13 -0
- package/dist/audit/a11y.js +33 -0
- package/dist/audit/audit.d.ts +31 -0
- package/dist/audit/audit.js +143 -0
- package/dist/audit/budgets.d.ts +22 -0
- package/dist/audit/budgets.js +106 -0
- package/dist/audit/lighthouse.d.ts +3 -0
- package/dist/audit/lighthouse.js +48 -0
- package/dist/audit/remote.d.ts +65 -0
- package/dist/audit/remote.js +123 -0
- package/dist/audit/vitals.d.ts +28 -0
- package/dist/audit/vitals.js +18 -0
- package/dist/checks/check.d.ts +29 -0
- package/dist/checks/check.js +330 -0
- package/dist/cli/create.d.ts +10 -0
- package/dist/cli/create.js +39 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +604 -0
- package/dist/cli/info.d.ts +47 -0
- package/dist/cli/info.js +55 -0
- package/dist/content/markdown.d.ts +26 -0
- package/dist/content/markdown.js +51 -0
- package/dist/content/sanitize.d.ts +7 -0
- package/dist/content/sanitize.js +17 -0
- package/dist/core/build.d.ts +25 -0
- package/dist/core/build.js +54 -0
- package/dist/core/config.d.ts +171 -0
- package/dist/core/config.js +243 -0
- package/dist/core/errors.d.ts +5 -0
- package/dist/core/errors.js +9 -0
- package/dist/core/html.d.ts +2 -0
- package/dist/core/html.js +11 -0
- package/dist/core/loader.d.ts +10 -0
- package/dist/core/loader.js +16 -0
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +14 -0
- package/dist/core/render.d.ts +37 -0
- package/dist/core/render.js +62 -0
- package/dist/core/routes.d.ts +24 -0
- package/dist/core/routes.js +70 -0
- package/dist/core/site.d.ts +31 -0
- package/dist/core/site.js +159 -0
- package/dist/core/validate.d.ts +10 -0
- package/dist/core/validate.js +30 -0
- package/dist/deploy/cloudflare-pages.d.ts +20 -0
- package/dist/deploy/cloudflare-pages.js +40 -0
- package/dist/deploy/git.d.ts +20 -0
- package/dist/deploy/git.js +55 -0
- package/dist/deploy/github-pages.d.ts +33 -0
- package/dist/deploy/github-pages.js +99 -0
- package/dist/deploy/headers.d.ts +11 -0
- package/dist/deploy/headers.js +103 -0
- package/dist/deploy/publish.d.ts +45 -0
- package/dist/deploy/publish.js +148 -0
- package/dist/deploy/redirects.d.ts +7 -0
- package/dist/deploy/redirects.js +18 -0
- package/dist/deploy/secrets.d.ts +6 -0
- package/dist/deploy/secrets.js +66 -0
- package/dist/dev/guard.d.ts +9 -0
- package/dist/dev/guard.js +57 -0
- package/dist/dev/listen.d.ts +6 -0
- package/dist/dev/listen.js +13 -0
- package/dist/dev/preview.d.ts +6 -0
- package/dist/dev/preview.js +72 -0
- package/dist/dev/server.d.ts +8 -0
- package/dist/dev/server.js +102 -0
- package/dist/discovery/crawlers.d.ts +13 -0
- package/dist/discovery/crawlers.js +2 -0
- package/dist/discovery/llms.d.ts +12 -0
- package/dist/discovery/llms.js +19 -0
- package/dist/discovery/robots.d.ts +8 -0
- package/dist/discovery/robots.js +40 -0
- package/dist/discovery/sitemap.d.ts +8 -0
- package/dist/discovery/sitemap.js +11 -0
- package/dist/guidelines/monitor.d.ts +55 -0
- package/dist/guidelines/monitor.js +126 -0
- package/dist/guidelines/pack.d.ts +41 -0
- package/dist/guidelines/pack.js +13 -0
- package/dist/guidelines/rule-pack.json +611 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +9 -0
- package/dist/integrations/api-keys.d.ts +26 -0
- package/dist/integrations/api-keys.js +65 -0
- package/dist/integrations/ga4-data.d.ts +13 -0
- package/dist/integrations/ga4-data.js +39 -0
- package/dist/integrations/google-auth.d.ts +47 -0
- package/dist/integrations/google-auth.js +157 -0
- package/dist/integrations/http.d.ts +15 -0
- package/dist/integrations/http.js +40 -0
- package/dist/integrations/indexnow.d.ts +18 -0
- package/dist/integrations/indexnow.js +38 -0
- package/dist/integrations/search-console.d.ts +25 -0
- package/dist/integrations/search-console.js +39 -0
- package/dist/integrations/tags.d.ts +22 -0
- package/dist/integrations/tags.js +67 -0
- package/dist/seo/head.d.ts +20 -0
- package/dist/seo/head.js +85 -0
- package/dist/seo/jsonld.d.ts +35 -0
- package/dist/seo/jsonld.js +31 -0
- package/dist/seo/urls.d.ts +3 -0
- package/dist/seo/urls.js +10 -0
- package/package.json +81 -4
- package/templates/starter/.env.example +12 -0
- package/templates/starter/.github/workflows/ci.yml +28 -0
- package/templates/starter/AGENTS.md +145 -0
- package/templates/starter/CLAUDE.md +3 -0
- package/templates/starter/_gitignore +8 -0
- package/templates/starter/package.json +16 -0
- package/templates/starter/public/apple-touch-icon.png +0 -0
- package/templates/starter/public/favicon.ico +0 -0
- package/templates/starter/public/favicon.svg +1 -0
- package/templates/starter/src/layouts/default.ts +37 -0
- package/templates/starter/src/pages/404.md +7 -0
- package/templates/starter/src/pages/about.md +8 -0
- package/templates/starter/src/pages/blog/first-post.md +10 -0
- package/templates/starter/src/pages/index.md +8 -0
- package/templates/starter/vage.config.ts +34 -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,80 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/brand/vagejs-logo.svg" alt="VAGE JS" width="320">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
# VAGE JS
|
|
6
|
+
|
|
7
|
+
An AI-native, static-first web framework. **Pre-release (0.x).**
|
|
8
|
+
|
|
9
|
+
## What works today
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
vage create <dir> [--vage-spec <spec>] [--json] # scaffold a Markdown site
|
|
13
|
+
vage dev [--root .] [--port N] [--host] # dev server with live reload
|
|
14
|
+
vage build [--root .] [--json] # static build into dist/ (+ robots.txt, sitemap.xml, llms.txt)
|
|
15
|
+
vage check [--root .] [--json] [--strict] # SEO, links, JSON-LD, basic a11y; writes nothing
|
|
16
|
+
vage audit [--root .] [--lighthouse] [--url U] [--strategy mobile|desktop|both] [--strict] [--json]
|
|
17
|
+
# budgets + axe a11y; optional Lighthouse; PageSpeed/CrUX for deployed URLs
|
|
18
|
+
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)
|
|
20
|
+
vage deploy [--remote URL] [--no-wait] [--dry-run] [--yes] # GitHub Pages via Actions; confirms with GitHub
|
|
21
|
+
vage deploy --target cloudflare-pages --project NAME [--branch B] # Wrangler upload; preview URL per branch
|
|
22
|
+
vage indexnow --changed-since REF | --urls … | --all # IndexNow URL notification (asks first)
|
|
23
|
+
vage connect gsc|ga4 / vage disconnect google # read-only Google OAuth (PKCE, loopback); token outside the repo
|
|
24
|
+
vage connect pagespeed|crux / vage disconnect … # save your own Google API key for audit --url (outside the repo)
|
|
25
|
+
vage report search|analytics [--days N] [--json] # Search Console top queries/pages; GA4 top pages
|
|
26
|
+
vage guidelines [--json] # rule pack: provider facts in use, sources, changelog
|
|
27
|
+
vage info [--json] # commands + config/frontmatter JSON Schemas for agents
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Generated sites include `AGENTS.md` (conventions, recipes, definition of done) and `CLAUDE.md` (imports AGENTS.md).
|
|
31
|
+
|
|
32
|
+
Failures exit with code 1. With `--json`, output is `{ "ok": true, ... }` or `{ "ok": false, "error": "..." }`.
|
|
33
|
+
|
|
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.
|
|
35
|
+
|
|
36
|
+
## Getting started
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm create vagejs@latest my-site
|
|
40
|
+
cd my-site
|
|
41
|
+
npm install
|
|
42
|
+
npm run dev
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Releasing
|
|
46
|
+
|
|
47
|
+
See [docs/RELEASING.md](docs/RELEASING.md).
|
|
48
|
+
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm run typecheck
|
|
53
|
+
npm test # unit + integration (dev server, preview, build)
|
|
54
|
+
npm run test:e2e # pack -> create -> npm install/ci -> build (needs npm registry or cache)
|
|
55
|
+
```
|
|
56
|
+
|
|
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).
|
|
64
|
+
|
|
65
|
+
## Brand assets
|
|
66
|
+
|
|
67
|
+
- `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.
|
|
69
|
+
- New sites get `favicon.svg`, `favicon.ico` (16/32/48) and `apple-touch-icon.png` (180) in `public/`.
|
|
70
|
+
|
|
71
|
+
Regenerate (maintainer tools; need Chrome, and Python with `fonttools` and `brotli`):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
node scripts/brand/favicons.mjs templates/starter/public/favicon.svg templates/starter/public
|
|
75
|
+
python -I scripts/brand/embed-logo-font.py "SpaceGrotesk[wght].ttf" <original-logo.svg> assets/brand/vagejs-logo.svg
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
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,48 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { VageError } from "../core/errors.js";
|
|
4
|
+
import { labReport } from "./remote.js";
|
|
5
|
+
/**
|
|
6
|
+
* Loads Lighthouse from the user's project. It is an optional peer dependency
|
|
7
|
+
* (large, and needs Chrome), so it is never required for build or check.
|
|
8
|
+
*/
|
|
9
|
+
async function loadLighthouse(root) {
|
|
10
|
+
const require = createRequire(pathToFileURL(`${root}/package.json`));
|
|
11
|
+
let entry;
|
|
12
|
+
try {
|
|
13
|
+
entry = require.resolve("lighthouse");
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new VageError('Lighthouse is not installed. Run "npm install --save-dev lighthouse" (requires Chrome), or audit without --lighthouse.');
|
|
17
|
+
}
|
|
18
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
19
|
+
// chrome-launcher is a Lighthouse dependency; resolve it from there.
|
|
20
|
+
const launcher = await import(pathToFileURL(createRequire(entry).resolve("chrome-launcher")).href);
|
|
21
|
+
return { lighthouse: mod.default, desktopConfig: mod.desktopConfig, launcher };
|
|
22
|
+
}
|
|
23
|
+
/** Runs Lighthouse (lab) against already-served URLs. */
|
|
24
|
+
export async function runLighthouse(root, urls, strategies) {
|
|
25
|
+
const { lighthouse, desktopConfig, launcher } = await loadLighthouse(root);
|
|
26
|
+
let chrome;
|
|
27
|
+
try {
|
|
28
|
+
chrome = await launcher.launch({ chromeFlags: ["--headless=new"] });
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
throw new VageError(`Could not start Chrome for Lighthouse: ${err.message}. Install Chrome or set CHROME_PATH.`);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const reports = [];
|
|
35
|
+
for (const url of urls) {
|
|
36
|
+
for (const strategy of strategies) {
|
|
37
|
+
const result = await lighthouse(url, { port: chrome.port, output: "json", logLevel: "error", onlyCategories: ["performance", "accessibility", "seo", "best-practices"] }, strategy === "desktop" ? desktopConfig : undefined);
|
|
38
|
+
if (!result)
|
|
39
|
+
throw new VageError(`Lighthouse returned no result for ${url}.`);
|
|
40
|
+
reports.push(labReport(result.lhr, "lighthouse", url, strategy));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return reports;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
chrome.kill();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact, type HttpOptions } from "../integrations/http.js";
|
|
3
|
+
import { type Rating, type VitalName } from "./vitals.js";
|
|
4
|
+
export type Strategy = "mobile" | "desktop";
|
|
5
|
+
export type RemoteOptions = HttpOptions;
|
|
6
|
+
export { redact };
|
|
7
|
+
declare const CATEGORY_IDS: readonly ["performance", "accessibility", "seo", "best-practices"];
|
|
8
|
+
declare const psiSchema: z.ZodObject<{
|
|
9
|
+
lighthouseResult: z.ZodObject<{
|
|
10
|
+
lighthouseVersion: z.ZodString;
|
|
11
|
+
fetchTime: z.ZodString;
|
|
12
|
+
finalDisplayedUrl: z.ZodOptional<z.ZodString>;
|
|
13
|
+
categories: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
14
|
+
score: z.ZodNullable<z.ZodNumber>;
|
|
15
|
+
}, z.core.$strip>>;
|
|
16
|
+
audits: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
17
|
+
title: z.ZodString;
|
|
18
|
+
score: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
19
|
+
numericValue: z.ZodOptional<z.ZodNumber>;
|
|
20
|
+
displayValue: z.ZodOptional<z.ZodString>;
|
|
21
|
+
}, z.core.$strip>>;
|
|
22
|
+
}, z.core.$strip>;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
export interface LabReport {
|
|
25
|
+
source: "pagespeed-insights" | "lighthouse";
|
|
26
|
+
url: string;
|
|
27
|
+
strategy: Strategy;
|
|
28
|
+
fetchTime: string;
|
|
29
|
+
toolVersion: string;
|
|
30
|
+
/** 0-100, or null when the tool returned no score. */
|
|
31
|
+
scores: Record<(typeof CATEGORY_IDS)[number], number | null>;
|
|
32
|
+
metrics: {
|
|
33
|
+
lcpMs?: number;
|
|
34
|
+
cls?: number;
|
|
35
|
+
tbtMs?: number;
|
|
36
|
+
fcpMs?: number;
|
|
37
|
+
speedIndexMs?: number;
|
|
38
|
+
};
|
|
39
|
+
/** Lowest-scoring audits, most impactful first. */
|
|
40
|
+
opportunities: {
|
|
41
|
+
id: string;
|
|
42
|
+
title: string;
|
|
43
|
+
displayValue?: string;
|
|
44
|
+
}[];
|
|
45
|
+
}
|
|
46
|
+
/** Normalizes a Lighthouse result (from PSI or a local run). */
|
|
47
|
+
export declare function labReport(lhr: z.infer<typeof psiSchema>["lighthouseResult"], source: LabReport["source"], url: string, strategy: Strategy): LabReport;
|
|
48
|
+
export declare function pageSpeed(url: string, strategy: Strategy, key: string | undefined, opts?: RemoteOptions): Promise<LabReport>;
|
|
49
|
+
export interface FieldReport {
|
|
50
|
+
source: "crux";
|
|
51
|
+
/** "url" when page-level data exists, otherwise "origin". */
|
|
52
|
+
scope: "url" | "origin";
|
|
53
|
+
formFactor: "PHONE" | "DESKTOP";
|
|
54
|
+
collectionPeriod?: string;
|
|
55
|
+
metrics: Partial<Record<VitalName, {
|
|
56
|
+
p75: number;
|
|
57
|
+
rating: Rating;
|
|
58
|
+
}>>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Queries the CrUX API for the page, falling back to the origin. Returns
|
|
62
|
+
* undefined when neither has data. A 404 is treated as "no data"; the API
|
|
63
|
+
* docs do not specify this response, so it is an observed-behavior assumption.
|
|
64
|
+
*/
|
|
65
|
+
export declare function cruxField(url: string, strategy: Strategy, key: string, opts?: RemoteOptions): Promise<FieldReport | undefined>;
|