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.
- package/LICENSE +21 -0
- package/README.md +244 -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 +31 -0
- package/dist/checks/check.js +434 -0
- package/dist/checks/integrity.d.ts +36 -0
- package/dist/checks/integrity.js +145 -0
- package/dist/checks/links.d.ts +18 -0
- package/dist/checks/links.js +47 -0
- package/dist/checks/verify.d.ts +35 -0
- package/dist/checks/verify.js +167 -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 +710 -0
- package/dist/cli/info.d.ts +82 -0
- package/dist/cli/info.js +100 -0
- package/dist/content/images.d.ts +23 -0
- package/dist/content/images.js +96 -0
- package/dist/content/markdown.d.ts +28 -0
- package/dist/content/markdown.js +56 -0
- package/dist/content/sanitize.d.ts +16 -0
- package/dist/content/sanitize.js +57 -0
- package/dist/core/build.d.ts +25 -0
- package/dist/core/build.js +54 -0
- package/dist/core/clone.d.ts +6 -0
- package/dist/core/clone.js +16 -0
- package/dist/core/config.d.ts +180 -0
- package/dist/core/config.js +260 -0
- package/dist/core/errors.d.ts +7 -0
- package/dist/core/errors.js +12 -0
- package/dist/core/html.d.ts +24 -0
- package/dist/core/html.js +42 -0
- package/dist/core/loader.d.ts +10 -0
- package/dist/core/loader.js +16 -0
- package/dist/core/pages.d.ts +40 -0
- package/dist/core/pages.js +93 -0
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +14 -0
- package/dist/core/render.d.ts +53 -0
- package/dist/core/render.js +104 -0
- package/dist/core/routes.d.ts +24 -0
- package/dist/core/routes.js +70 -0
- package/dist/core/site.d.ts +42 -0
- package/dist/core/site.js +182 -0
- package/dist/core/validate.d.ts +12 -0
- package/dist/core/validate.js +36 -0
- package/dist/deploy/cloudflare-pages.d.ts +20 -0
- package/dist/deploy/cloudflare-pages.js +42 -0
- package/dist/deploy/cloudflare-workers.d.ts +59 -0
- package/dist/deploy/cloudflare-workers.js +223 -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 +104 -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 +14 -0
- package/dist/index.js +12 -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/dependabot.yml +11 -0
- package/templates/starter/.github/workflows/ci.yml +30 -0
- package/templates/starter/.github/workflows/verify.yml +32 -0
- package/templates/starter/AGENTS.md +166 -0
- package/templates/starter/CLAUDE.md +3 -0
- package/templates/starter/_gitignore +8 -0
- package/templates/starter/links.lock.json +4 -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/blog.ts +13 -0
- package/templates/starter/src/layouts/default.ts +38 -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/blog/index.md +7 -0
- package/templates/starter/src/pages/index.md +8 -0
- 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
|
-
|
|
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
|
-
|
|
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 `` 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
|
+
}
|