vagejs 0.3.5 → 0.5.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/README.md +32 -6
- package/dist/audit/budgets.js +21 -1
- package/dist/checks/check.js +63 -5
- package/dist/cli/index.js +77 -5
- package/dist/cli/info.d.ts +1 -0
- package/dist/cli/info.js +7 -6
- package/dist/cli/new.d.ts +27 -0
- package/dist/cli/new.js +97 -0
- package/dist/content/markdown.d.ts +1 -0
- package/dist/content/markdown.js +4 -0
- package/dist/content/optimize.d.ts +8 -4
- package/dist/content/optimize.js +35 -22
- package/dist/content/sanitize.d.ts +12 -9
- package/dist/content/sanitize.js +86 -10
- package/dist/core/build.d.ts +5 -0
- package/dist/core/build.js +1 -0
- package/dist/core/config.d.ts +8 -0
- package/dist/core/config.js +24 -4
- package/dist/core/dates.d.ts +6 -0
- package/dist/core/dates.js +14 -0
- package/dist/core/pages.d.ts +15 -1
- package/dist/core/pages.js +27 -5
- package/dist/core/render.d.ts +2 -0
- package/dist/core/render.js +4 -4
- package/dist/core/site.d.ts +9 -0
- package/dist/core/site.js +49 -7
- package/dist/deploy/netlify.d.ts +22 -0
- package/dist/deploy/netlify.js +48 -0
- package/dist/deploy/redirects.d.ts +5 -0
- package/dist/deploy/redirects.js +8 -0
- package/dist/dev/guard.js +1 -1
- package/dist/dev/server.js +15 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp/server.d.ts +27 -0
- package/dist/mcp/server.js +81 -0
- package/package.json +2 -1
- package/templates/starter/AGENTS.md +26 -10
- package/templates/starter/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,13 +13,18 @@
|
|
|
13
13
|
|
|
14
14
|
# vagejs
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
**A free static site generator built for AI coding assistants.** Your assistant (Claude Code, Cursor, Codex, Copilot) writes the pages; vagejs checks them before anything goes live. Write pages in Markdown and get a fast static site with SEO, `llms.txt`, accessibility and security handled for you. **Pre-release (0.x).**
|
|
17
|
+
|
|
18
|
+
[Website](https://vagejs.com) · [Docs](https://vagejs.com/docs/) · [Use with AI assistants](https://vagejs.com/agents/) · [Changelog](https://vagejs.com/changelog/) · [Discussions](https://github.com/agevenkat/vagejs/discussions) · [LinkedIn](https://www.linkedin.com/company/vagejs/)
|
|
19
|
+
|
|
20
|
+
<p align="center"><img src="https://raw.githubusercontent.com/agevenkat/vagejs/main/assets/demo.svg" alt="Terminal demo: create a vagejs site, install it, npm run check catches a broken link and names the file, the link is fixed, the check passes, and npm start runs the site." width="760"></p>
|
|
17
21
|
|
|
18
22
|
- **Static by default**: plain HTML, no client JavaScript unless you add analytics.
|
|
19
23
|
- **SEO built in**: `robots.txt`, `sitemap.xml`, `llms.txt`, canonical URLs, Open Graph and JSON-LD, generated from your pages.
|
|
20
24
|
- **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.
|
|
25
|
+
- **Safe by default**: page content is sanitized; secrets, build output and force-pushes are refused; Cloudflare and Netlify deploys get a computed Content-Security-Policy.
|
|
22
26
|
- **Agent-friendly**: every site ships with `AGENTS.md`, and `vage info --json` describes every command and config option.
|
|
27
|
+
- **Runs everywhere**: macOS, Windows and Linux, tested on every change. Deploys to GitHub Pages, Cloudflare Workers and Netlify built in; any other static host (Vercel) works with `npm run build` and the `dist` folder.
|
|
23
28
|
|
|
24
29
|
## Quick start
|
|
25
30
|
|
|
@@ -31,9 +36,17 @@ cd my-site
|
|
|
31
36
|
npm install
|
|
32
37
|
npm start
|
|
33
38
|
```
|
|
34
|
-
|
|
35
39
|
`npm start` opens the site in your browser. Edit `src/pages/index.md` and the page reloads. (`npm run dev` does the same without opening a browser, and prints the address.)
|
|
36
40
|
|
|
41
|
+
### Windows
|
|
42
|
+
|
|
43
|
+
vagejs works on Windows and is tested on Windows on every change.
|
|
44
|
+
|
|
45
|
+
- **Node.js:** install Node.js **22.12 or newer** before creating a project. Then open a new PowerShell or Command Prompt window and check it with `node --version` and `npm --version`.
|
|
46
|
+
- **PowerShell or Command Prompt:** the `npm` and `npx` commands in this README work in both. Command Prompt doesn't treat `#` as a comment, so leave out the `# …` notes shown after some commands.
|
|
47
|
+
- **Line endings:** Windows usually saves files with CRLF line endings. vagejs handles them, so there's no need to convert files to LF.
|
|
48
|
+
- **HTTP 431 in the browser:** open the direct address (`[::1]` or `127.0.0.1`) that `npm start` and `npm run dev` print. It's the same server, without the cookies other local projects left on `localhost`.
|
|
49
|
+
|
|
37
50
|
## Using vagejs
|
|
38
51
|
|
|
39
52
|
### Project layout
|
|
@@ -66,9 +79,9 @@ description: Plans and prices.
|
|
|
66
79
|
Our plans start at …
|
|
67
80
|
```
|
|
68
81
|
|
|
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 `
|
|
82
|
+
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`, `dateModified` and `tags` are optional (a future `datePublished` schedules the page; each tag gets a page at `/tags/<tag>/`); see [`templates/starter/AGENTS.md`](templates/starter/AGENTS.md).
|
|
70
83
|
|
|
71
|
-
Images in `public/` used as `` get their `width` and `height` from the file, so the page doesn't jump while loading. With `images: { optimize: true }` (and `npm install --save-dev sharp`), vagejs also makes resized WebP copies of images in page content and offers them with `srcset`.
|
|
84
|
+
Images in `public/` used as `` get their `width` and `height` from the file, so the page doesn't jump while loading. With `images: { optimize: true }` (and `npm install --save-dev sharp`), vagejs also makes resized WebP copies of images in page content and offers them with `srcset`. Add `avif: true` for AVIF copies too, offered first through `<picture>` (usually smaller; slower to make). `vage audit` counts the copy browsers download, not the original.
|
|
72
85
|
|
|
73
86
|
### Navigation, feeds and AI extras
|
|
74
87
|
|
|
@@ -140,6 +153,7 @@ npx vage deploy:github --remote https://github.com/you/site.git # check, build
|
|
|
140
153
|
npx vage deploy # GitHub Pages via Actions
|
|
141
154
|
npx vage deploy --target cloudflare-workers --project my-site # Cloudflare Workers: new version, no downtime
|
|
142
155
|
npx vage deploy --target cloudflare-pages --project my-site # older Cloudflare Pages projects
|
|
156
|
+
npx vage deploy --target netlify --project my-site # Netlify production deploy (deploy.target: "netlify")
|
|
143
157
|
```
|
|
144
158
|
|
|
145
159
|
Every deploy shows its plan and asks for confirmation (`--dry-run` to preview, `--yes` to skip). It reports success only after the host confirms.
|
|
@@ -173,6 +187,15 @@ npx vage info --json # commands, options, config and frontmatter JSON Schemas
|
|
|
173
187
|
npx vage check --json # findings with file, rule and fix hints
|
|
174
188
|
```
|
|
175
189
|
|
|
190
|
+
Or let the assistant call vagejs directly over MCP. `npx vage mcp` runs an MCP server with read-only tools (`vage_info`, `vage_check`, `vage_pages`, `vage_links`); it needs the optional SDK:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npm install --save-dev @modelcontextprotocol/sdk
|
|
194
|
+
claude mcp add --transport stdio --scope project vagejs -- npx -p vagejs vage mcp # Claude Code, in the project folder
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Other assistants: `{ "mcpServers": { "vagejs": { "command": "npx", "args": ["-p", "vagejs", "vage", "mcp", "--root", "/absolute/path/to/the/site"] } } }`. `-p vagejs` makes npx run the vagejs installed in the project, and never a different package that happens to be called `vage`. Approving link domains and deploying stay CLI commands for the site owner.
|
|
198
|
+
|
|
176
199
|
## Command reference
|
|
177
200
|
|
|
178
201
|
```bash
|
|
@@ -187,6 +210,7 @@ vage deploy:github [--remote URL] [--branch B] [-m MSG] [--dry-run] [--yes] [--j
|
|
|
187
210
|
vage deploy [--remote URL] [--no-wait] [--dry-run] [--yes] # GitHub Pages via Actions; confirms with GitHub
|
|
188
211
|
vage deploy --target cloudflare-workers [--project NAME] # new Worker version at 100%; custom domains stay up
|
|
189
212
|
vage deploy --target cloudflare-pages --project NAME [--branch B] # Wrangler upload; preview URL per branch
|
|
213
|
+
vage deploy --target netlify [--project SITE] # netlify-cli production deploy, then a live check
|
|
190
214
|
vage indexnow --changed-since REF | --urls … | --all # IndexNow URL notification (asks first)
|
|
191
215
|
vage connect gsc|ga4 / vage disconnect google # read-only Google OAuth (PKCE, loopback); token outside the repo
|
|
192
216
|
vage connect pagespeed|crux / vage disconnect … # save your own Google API key for audit --url (outside the repo)
|
|
@@ -197,7 +221,7 @@ vage info [--json] # commands + config/frontmatte
|
|
|
197
221
|
|
|
198
222
|
## Planned, not implemented
|
|
199
223
|
|
|
200
|
-
Bing Webmaster Tools API,
|
|
224
|
+
Bing Webmaster Tools API, a Vercel adapter, CMS content sources, multi-language sites, base paths, sitemap indexes.
|
|
201
225
|
|
|
202
226
|
## Security
|
|
203
227
|
|
|
@@ -225,6 +249,8 @@ Also protect the accounts around the site: two-factor authentication on GitHub,
|
|
|
225
249
|
|
|
226
250
|
## Contributing
|
|
227
251
|
|
|
252
|
+
New contributors are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) and the issues labeled [good first issue](https://github.com/agevenkat/vagejs/labels/good%20first%20issue). Questions and ideas go in [Discussions](https://github.com/agevenkat/vagejs/discussions).
|
|
253
|
+
|
|
228
254
|
```bash
|
|
229
255
|
npm install
|
|
230
256
|
npm run typecheck
|
package/dist/audit/budgets.js
CHANGED
|
@@ -64,11 +64,31 @@ export function measurePages(outDir, pages, site) {
|
|
|
64
64
|
for (const l of doc.querySelectorAll('link[rel="preload"][as="font"]'))
|
|
65
65
|
add(l.getAttribute("href"), "font", route);
|
|
66
66
|
for (const img of doc.querySelectorAll("img"))
|
|
67
|
-
add(img
|
|
67
|
+
add(downloadedImage(img), "image", route);
|
|
68
68
|
weight.total = Object.values(weight.bytes).reduce((a, b) => a + b, 0);
|
|
69
69
|
return weight;
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The file a browser actually downloads for an <img>: the first <source> of its
|
|
74
|
+
* <picture> (AVIF from images.avif), else its srcset (WebP), else src. With several
|
|
75
|
+
* widths, the largest counts: what a wide, high-density screen loads.
|
|
76
|
+
*/
|
|
77
|
+
function downloadedImage(img) {
|
|
78
|
+
const source = img.parentNode?.rawTagName?.toLowerCase() === "picture" ? img.parentNode.querySelector("source[srcset]") : null;
|
|
79
|
+
const srcset = source?.getAttribute("srcset") ?? img.getAttribute("srcset");
|
|
80
|
+
if (!srcset)
|
|
81
|
+
return img.getAttribute("src");
|
|
82
|
+
let best;
|
|
83
|
+
for (const entry of srcset.split(",")) {
|
|
84
|
+
const [url, descriptor = "1x"] = entry.trim().split(/\s+/);
|
|
85
|
+
// "960w" (width) or "2x" (density): either way, the biggest number is the biggest file.
|
|
86
|
+
const size = Number(/^([\d.]+)[wx]$/.exec(descriptor)?.[1] ?? 0);
|
|
87
|
+
if (url && (!best || size > best.size))
|
|
88
|
+
best = { url, size };
|
|
89
|
+
}
|
|
90
|
+
return best?.url ?? img.getAttribute("src");
|
|
91
|
+
}
|
|
72
92
|
const BUDGET_KEYS = [
|
|
73
93
|
["javascriptKb", "javascript"],
|
|
74
94
|
["cssKb", "css"],
|
package/dist/checks/check.js
CHANGED
|
@@ -62,10 +62,17 @@ export function checkSite(root, model) {
|
|
|
62
62
|
if (e.route)
|
|
63
63
|
outputs.add(`${e.route}index.html`);
|
|
64
64
|
const drafts = new Set(model.draftRoutes);
|
|
65
|
+
const scheduled = new Map(model.skippedScheduled.map((s) => [s.route, s.date]));
|
|
65
66
|
const pages = model.notFound ? [...model.pages, model.notFound] : model.pages;
|
|
66
|
-
|
|
67
|
+
const docs = pages.map((page) => ({ page, doc: parse(page.html) }));
|
|
68
|
+
// Link targets on each output file: element ids and <a name>, for #fragment links.
|
|
69
|
+
const anchors = new Map(docs.map(({ page, doc }) => [
|
|
70
|
+
`/${page.outFile}`,
|
|
71
|
+
new Set([...doc.querySelectorAll("[id]").map((el) => el.getAttribute("id")), ...doc.querySelectorAll("a[name]").map((el) => el.getAttribute("name"))]),
|
|
72
|
+
]));
|
|
73
|
+
for (const { page, doc } of docs) {
|
|
67
74
|
const add = (f) => findings.push({ ...f, page: page.route, source: page.source });
|
|
68
|
-
checkPage(page,
|
|
75
|
+
checkPage(page, doc, model, outputs, drafts, scheduled, anchors, add);
|
|
69
76
|
}
|
|
70
77
|
findings.push(...checkDuplicates(listedPages(model)));
|
|
71
78
|
findings.push(...checkRobots(model));
|
|
@@ -73,7 +80,7 @@ export function checkSite(root, model) {
|
|
|
73
80
|
findings.push(...checkOutboundLinks(root, model));
|
|
74
81
|
return findings;
|
|
75
82
|
}
|
|
76
|
-
function checkPage(page, doc, model, outputs, drafts, add) {
|
|
83
|
+
function checkPage(page, doc, model, outputs, drafts, scheduled, anchors, add) {
|
|
77
84
|
const indexable = !page.meta.noindex;
|
|
78
85
|
if (model.site.security.spamPatterns) {
|
|
79
86
|
const text = `${doc.querySelector("body")?.text ?? ""} ${doc.querySelectorAll("a[href]").map((a) => a.getAttribute("href")).join(" ")}`;
|
|
@@ -101,6 +108,25 @@ function checkPage(page, doc, model, outputs, drafts, add) {
|
|
|
101
108
|
if (titles.length !== 1 || !titles[0].text.trim()) {
|
|
102
109
|
add({ rule: "title", severity: "error", message: `Expected exactly one non-empty <title>, found ${titles.length}.`, docs: DOCS.title });
|
|
103
110
|
}
|
|
111
|
+
// Only pages shown in search results can be shortened there.
|
|
112
|
+
const renderedTitle = titles[0]?.text.trim() ?? "";
|
|
113
|
+
if (indexable && renderedTitle.length > 60) {
|
|
114
|
+
add({
|
|
115
|
+
rule: "title-length",
|
|
116
|
+
severity: "warning",
|
|
117
|
+
message: `Rendered <title> in "${page.source}" is ${renderedTitle.length} characters; it may be shortened in search results.`,
|
|
118
|
+
docs: DOCS.title,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const description = doc.querySelector('meta[name="description"]')?.getAttribute("content")?.trim() ?? "";
|
|
122
|
+
if (indexable && description.length > 160) {
|
|
123
|
+
add({
|
|
124
|
+
rule: "meta-description-length",
|
|
125
|
+
severity: "warning",
|
|
126
|
+
message: `Meta description in "${page.source}" is ${description.length} characters; it may be shortened in search results.`,
|
|
127
|
+
docs: DOCS.description,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
104
130
|
if (indexable && !doc.querySelector('meta[name="description"]')?.getAttribute("content")) {
|
|
105
131
|
add({
|
|
106
132
|
rule: "meta-description",
|
|
@@ -148,6 +174,10 @@ function checkPage(page, doc, model, outputs, drafts, add) {
|
|
|
148
174
|
if (!a.text.trim() && !a.getAttribute("aria-label") && !a.querySelector("img[alt]:not([alt=''])")) {
|
|
149
175
|
add({ rule: "link-text", severity: "warning", message: `Link to "${href}" has no accessible text.`, docs: DOCS.linkPurpose });
|
|
150
176
|
}
|
|
177
|
+
if (/^#./.test(href.trim())) {
|
|
178
|
+
checkFragment(href, `/${page.outFile}`, anchors, add);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
151
181
|
const target = internalPath(href, page.route, model.site.site);
|
|
152
182
|
if (target === undefined)
|
|
153
183
|
continue;
|
|
@@ -156,12 +186,19 @@ function checkPage(page, doc, model, outputs, drafts, add) {
|
|
|
156
186
|
continue;
|
|
157
187
|
}
|
|
158
188
|
const resolved = resolveOutput(target, outputs);
|
|
189
|
+
if (resolved === "ok")
|
|
190
|
+
checkFragment(href, target.endsWith("/") ? `${target}index.html` : target, anchors, add);
|
|
159
191
|
if (resolved === "missing") {
|
|
160
|
-
const
|
|
192
|
+
const route = target.endsWith("/") ? target : `${target}/`;
|
|
193
|
+
const date = scheduled.get(route);
|
|
161
194
|
add({
|
|
162
195
|
rule: "link-broken",
|
|
163
196
|
severity: "error",
|
|
164
|
-
message:
|
|
197
|
+
message: drafts.has(route)
|
|
198
|
+
? `Link to "${href}" points to a draft page, which is not published.`
|
|
199
|
+
: date
|
|
200
|
+
? `Link to "${href}" points to a page scheduled for ${date}, which is not published yet.`
|
|
201
|
+
: `Link to "${href}" does not match any page or file.`,
|
|
165
202
|
});
|
|
166
203
|
}
|
|
167
204
|
else if (resolved === "needs-slash") {
|
|
@@ -423,6 +460,27 @@ function internalPath(href, base, site) {
|
|
|
423
460
|
return MALFORMED;
|
|
424
461
|
}
|
|
425
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* A #fragment must match an id (or <a name>) on the page it points to; otherwise the
|
|
465
|
+
* page opens at the top instead of the section. "#top" and text fragments (#:~:text=)
|
|
466
|
+
* work without one. Files that aren't rendered pages (PDFs, redirects) aren't checked.
|
|
467
|
+
*/
|
|
468
|
+
function checkFragment(href, outFile, anchors, add) {
|
|
469
|
+
const ids = anchors.get(outFile);
|
|
470
|
+
const hash = href.indexOf("#");
|
|
471
|
+
if (!ids || hash === -1)
|
|
472
|
+
return;
|
|
473
|
+
let fragment;
|
|
474
|
+
try {
|
|
475
|
+
fragment = decodeURIComponent(href.slice(hash + 1));
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
fragment = href.slice(hash + 1);
|
|
479
|
+
}
|
|
480
|
+
if (!fragment || fragment.toLowerCase() === "top" || fragment.startsWith(":~:") || ids.has(fragment))
|
|
481
|
+
return;
|
|
482
|
+
add({ rule: "link-fragment", severity: "warning", message: `Link to "${href}": no element with id "${fragment}" on that page, so it opens at the top.` });
|
|
483
|
+
}
|
|
426
484
|
function resolveOutput(target, outputs) {
|
|
427
485
|
if (target.endsWith("/"))
|
|
428
486
|
return outputs.has(`${target}index.html`) ? "ok" : "missing";
|
package/dist/cli/index.js
CHANGED
|
@@ -56,6 +56,8 @@ cli
|
|
|
56
56
|
console.log(` ${"".padEnd(30)} -> ${file}`);
|
|
57
57
|
for (const draft of result.skippedDrafts)
|
|
58
58
|
console.log(` (draft, skipped) ${draft}`);
|
|
59
|
+
for (const s of result.skippedScheduled)
|
|
60
|
+
console.log(` (scheduled for ${s.date}, skipped) ${s.source}`);
|
|
59
61
|
for (const warning of result.warnings)
|
|
60
62
|
console.log(`\nWarning: ${warning}`);
|
|
61
63
|
console.log(`\nBuilt ${result.pages.length} page(s) into ${result.outDir} in ${result.durationMs}ms`);
|
|
@@ -142,6 +144,35 @@ cli
|
|
|
142
144
|
if (opts.open && target)
|
|
143
145
|
openBrowser(target);
|
|
144
146
|
}));
|
|
147
|
+
cli
|
|
148
|
+
.command("new <kind> <name>", 'Start a page (vage new page pricing) or a post (vage new post "Our first launch") as a draft with valid frontmatter')
|
|
149
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
150
|
+
.option("--title <text>", "Page title (pages; default from the path)")
|
|
151
|
+
.option("--description <text>", "One or two sentences for search results")
|
|
152
|
+
.option("--under <folder>", "Posts: section folder under src/pages (default: blog)")
|
|
153
|
+
.option("--date <YYYY-MM-DD>", "Posts: datePublished (default: today)")
|
|
154
|
+
.option("--author <name>", "Posts: author (only written when given)")
|
|
155
|
+
.option("--slug <name>", "Posts: file name, for titles without Latin letters or digits")
|
|
156
|
+
.option("--json", "Print machine-readable JSON")
|
|
157
|
+
.action((kind, name, opts) => run(opts, async () => {
|
|
158
|
+
if (kind !== "page" && kind !== "post")
|
|
159
|
+
throw new VageError(`Unknown kind "${kind}". Use vage new page <path> or vage new post "<title>".`);
|
|
160
|
+
const { createPage } = await import("./new.js");
|
|
161
|
+
// cac turns numeric values (--title 123) into numbers, so every option is read as text.
|
|
162
|
+
const text = (v) => (v === undefined ? undefined : String(v));
|
|
163
|
+
const created = createPage(opts.root, kind, String(name), { title: text(opts.title), description: text(opts.description), under: text(opts.under), date: text(opts.date), author: text(opts.author), slug: text(opts.slug) });
|
|
164
|
+
if (opts.json)
|
|
165
|
+
return print({ ok: true, ...created });
|
|
166
|
+
console.log(`Created ${created.file} (draft) for ${created.route}`);
|
|
167
|
+
console.log("Preview it with npm start (drafts show there); remove `draft: true` to publish it, then run npm run check.");
|
|
168
|
+
}));
|
|
169
|
+
cli
|
|
170
|
+
.command("mcp", "Run an MCP server over stdio so AI assistants can check this site (read-only tools; needs @modelcontextprotocol/sdk)")
|
|
171
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
172
|
+
.action((opts) => run({}, async () => {
|
|
173
|
+
const { startMcpServer } = await import("../mcp/server.js");
|
|
174
|
+
await startMcpServer(opts.root, { version, info: () => frameworkInfo(cli, version) });
|
|
175
|
+
}));
|
|
145
176
|
cli
|
|
146
177
|
.command("info", "Describe commands, config and frontmatter (use --json for agents)")
|
|
147
178
|
.option("--json", "Print machine-readable JSON")
|
|
@@ -174,10 +205,10 @@ cli
|
|
|
174
205
|
reportPublish(result);
|
|
175
206
|
}));
|
|
176
207
|
cli
|
|
177
|
-
.command("deploy", "Deploy: GitHub Pages (workflow + push, waits for GitHub), Cloudflare Workers (new version, no downtime)
|
|
208
|
+
.command("deploy", "Deploy: GitHub Pages (workflow + push, waits for GitHub), Cloudflare Workers (new version, no downtime), Cloudflare Pages or Netlify")
|
|
178
209
|
.option("--root <dir>", "Project root", { default: "." })
|
|
179
|
-
.option("--target <host>", "github-pages, cloudflare-workers
|
|
180
|
-
.option("--project <name>", "Cloudflare Pages project or
|
|
210
|
+
.option("--target <host>", "github-pages, cloudflare-workers, cloudflare-pages or netlify (default: deploy.target in config)")
|
|
211
|
+
.option("--project <name>", "Cloudflare Pages project, Worker or Netlify site name (default: deploy.cloudflareProject / cloudflareWorker / netlifySite)")
|
|
181
212
|
.option("--remote <url>", "Git URL to add as origin if there is none")
|
|
182
213
|
.option("--branch <name>", "Branch for a new repository (default: main)")
|
|
183
214
|
.option("-m, --message <msg>", "Commit message")
|
|
@@ -193,8 +224,13 @@ cli
|
|
|
193
224
|
return deployToCloudflare(opts, config.deploy.cloudflareProject);
|
|
194
225
|
if (target === "cloudflare-workers")
|
|
195
226
|
return deployToWorkers(opts, config.deploy.cloudflareWorker ?? config.deploy.cloudflareProject, config.site);
|
|
227
|
+
if (target === "netlify") {
|
|
228
|
+
if (config.deploy.target !== "netlify")
|
|
229
|
+
throw new VageError('Set deploy.target to "netlify" in vage.config.ts, so the build writes Netlify\'s _redirects and _headers.');
|
|
230
|
+
return deployToNetlify(opts, config.deploy.netlifySite, config.site);
|
|
231
|
+
}
|
|
196
232
|
if (target !== "github-pages")
|
|
197
|
-
throw new VageError(`Unknown --target "${target}". Use github-pages, cloudflare-workers
|
|
233
|
+
throw new VageError(`Unknown --target "${target}". Use github-pages, cloudflare-workers, cloudflare-pages or netlify.`);
|
|
198
234
|
const { publish } = await import("../deploy/publish.js");
|
|
199
235
|
const pages = await import("../deploy/github-pages.js");
|
|
200
236
|
// Stop before pushing a site GitHub Pages would serve from a subfolder (its links would break).
|
|
@@ -460,7 +496,9 @@ cli
|
|
|
460
496
|
urls = opts.urls.split(",").map((u) => abs(u.trim()));
|
|
461
497
|
else if (opts.changedSince) {
|
|
462
498
|
const { changedPageRoutes } = await import("../deploy/git.js");
|
|
463
|
-
|
|
499
|
+
// Deleted pages are submitted too (so engines drop them); drafts and scheduled pages aren't live yet.
|
|
500
|
+
const unpublished = new Set([...model.draftRoutes, ...model.skippedScheduled.map((s) => s.route)]);
|
|
501
|
+
urls = (await changedPageRoutes(root, opts.changedSince)).filter((r) => !unpublished.has(r)).map(abs);
|
|
464
502
|
if (!urls.length)
|
|
465
503
|
return opts.json ? print({ ok: true, submitted: 0 }) : console.log(`No page changes since ${opts.changedSince}.`);
|
|
466
504
|
}
|
|
@@ -618,6 +656,40 @@ async function deployToWorkers(opts, configuredWorker, siteUrl) {
|
|
|
618
656
|
if (deployment.status === "failure")
|
|
619
657
|
process.exit(1);
|
|
620
658
|
}
|
|
659
|
+
async function deployToNetlify(opts, configuredSite, siteUrl) {
|
|
660
|
+
const site = opts.project ?? configuredSite;
|
|
661
|
+
if (!site)
|
|
662
|
+
throw new VageError("No Netlify site. Pass --project <site name or ID> or set deploy.netlifySite in vage.config.ts (create the site in Netlify first).");
|
|
663
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(site))
|
|
664
|
+
throw new VageError(`"${site}" isn't a Netlify site name or ID (lowercase letters, digits and hyphens).`);
|
|
665
|
+
const root = path.resolve(opts.root);
|
|
666
|
+
const { check } = await import("../checks/check.js");
|
|
667
|
+
const checked = await check(root);
|
|
668
|
+
if (checked.errors)
|
|
669
|
+
throw new VageError(`vage check found ${checked.errors} error(s). Fix them before deploying.`);
|
|
670
|
+
const result = await build(root);
|
|
671
|
+
const { deployNetlify, NETLIFY_CLI } = await import("../deploy/netlify.js");
|
|
672
|
+
const steps = [
|
|
673
|
+
`upload ${result.outDir} (${result.pages.length} page(s), ${result.files.length} generated file(s)) to the Netlify site "${site}" (production)`,
|
|
674
|
+
`runs: npx ${NETLIFY_CLI} deploy --dir dist --prod --no-build --site ${site}`,
|
|
675
|
+
`verifies: ${siteUrl ?? "the site's Netlify URL"} answers 200`,
|
|
676
|
+
];
|
|
677
|
+
const lines = ["Deploy plan (Netlify)", ...steps.map((s) => ` • ${s}`)];
|
|
678
|
+
if (!(await askConfirm(lines, opts))) {
|
|
679
|
+
if (opts.dryRun)
|
|
680
|
+
return opts.json ? print({ ok: true, dryRun: true, target: "netlify", site, steps }) : console.log("Dry run: nothing deployed.");
|
|
681
|
+
return opts.json ? print({ ok: false, status: "aborted" }) : console.log("Nothing deployed.");
|
|
682
|
+
}
|
|
683
|
+
const deployment = await deployNetlify(root, { site, siteUrl });
|
|
684
|
+
if (opts.json)
|
|
685
|
+
print({ ok: deployment.status !== "failure", target: "netlify", deployment });
|
|
686
|
+
else if (deployment.status === "success")
|
|
687
|
+
console.log(`Deployed and verified: ${deployment.pageUrl}${deployment.deployId ? ` (deploy ${deployment.deployId})` : ""}`);
|
|
688
|
+
else
|
|
689
|
+
console.log(`Deployment ${deployment.status === "failure" ? "FAILED" : "not confirmed"}: ${deployment.reason}`);
|
|
690
|
+
if (deployment.status === "failure")
|
|
691
|
+
process.exit(1);
|
|
692
|
+
}
|
|
621
693
|
async function confirmPlan(plan, opts, extra) {
|
|
622
694
|
const lines = [`Publish plan for ${plan.root}`];
|
|
623
695
|
if (plan.initRepo)
|
package/dist/cli/info.d.ts
CHANGED
package/dist/cli/info.js
CHANGED
|
@@ -34,7 +34,7 @@ export function frameworkInfo(cli, version) {
|
|
|
34
34
|
"two files mapping to one route is an error",
|
|
35
35
|
],
|
|
36
36
|
layouts: "see `layouts`",
|
|
37
|
-
drafts: "draft: true pages render in `vage dev` for preview, are skipped by build, and never appear in `pages
|
|
37
|
+
drafts: "draft: true pages render in `vage dev` for preview, are skipped by build, and never appear in `pages`. A future datePublished does the same until a build on or after that date (scheduled pages)",
|
|
38
38
|
},
|
|
39
39
|
layouts: {
|
|
40
40
|
file: "src/layouts/<name>.ts, chosen with `layout: <name>` in frontmatter (default: default)",
|
|
@@ -63,7 +63,7 @@ export function frameworkInfo(cli, version) {
|
|
|
63
63
|
feeds: 'seo.feeds: [{ under: "/blog/" }] writes /blog/feed.xml (RSS 2.0) and links it from that section\'s pages',
|
|
64
64
|
markdownCopies: "seo.markdownCopies: true publishes /<path>.md copies of listed pages, made from each page's <main> and linked with rel=alternate type=text/markdown; layouts mark repeated extras (share links, calls to action) with data-vage-copy=\"skip\". On Cloudflare, _headers gives each copy a canonical Link header to its page",
|
|
65
65
|
lastmod: 'seo.lastmod: "git" takes sitemap <lastmod> from each page\'s last commit (dateModified wins; skipped in shallow clones)',
|
|
66
|
-
images: 'images: { optimize: true } (needs the sharp package) makes resized WebP copies of content images in /_vage/img/ and adds srcset/sizes',
|
|
66
|
+
images: 'images: { optimize: true } (needs the sharp package) makes resized WebP copies of content images in /_vage/img/ and adds srcset/sizes; images: { avif: true } also makes AVIF copies, offered first through <picture>',
|
|
67
67
|
navigation: "frontmatter order and navTitle; helpers tree(), breadcrumbs(), neighbors(); PageSummary.label",
|
|
68
68
|
},
|
|
69
69
|
checks: {
|
|
@@ -88,8 +88,9 @@ export function frameworkInfo(cli, version) {
|
|
|
88
88
|
"llms-full.txt (opt-in)",
|
|
89
89
|
"404.html",
|
|
90
90
|
"redirect pages",
|
|
91
|
+
"/tags/<tag>/ pages for tags used in frontmatter (tags.pages)",
|
|
91
92
|
"<indexnow-key>.txt (opt-in)",
|
|
92
|
-
"_redirects and _headers (cloudflare-workers
|
|
93
|
+
"_redirects and _headers (cloudflare-workers, cloudflare-pages and netlify targets)",
|
|
93
94
|
"<section>/feed.xml (seo.feeds)",
|
|
94
95
|
"/<path>.md Markdown copies of listed pages (seo.markdownCopies)",
|
|
95
96
|
"/_vage/img/*.webp resized images (images.optimize)",
|
|
@@ -102,14 +103,14 @@ export function frameworkInfo(cli, version) {
|
|
|
102
103
|
VAGE_CONFIG_DIR: "optional; where the Google token is stored (default: OS user config dir)",
|
|
103
104
|
CLOUDFLARE_API_TOKEN: "for Cloudflare deploys (cloudflare-workers or cloudflare-pages targets), or run `npx wrangler login`",
|
|
104
105
|
CLOUDFLARE_ACCOUNT_ID: "for Cloudflare deploys (cloudflare-workers or cloudflare-pages targets)",
|
|
106
|
+
NETLIFY_AUTH_TOKEN: "for Netlify deploys (netlify target), or run `npx netlify-cli login`",
|
|
105
107
|
},
|
|
106
108
|
notImplemented: [
|
|
107
109
|
"Bing Webmaster Tools API (use IndexNow for URL submission)",
|
|
108
|
-
"
|
|
110
|
+
"A Vercel deploy adapter (Vercel serves dist/ as plain files)",
|
|
111
|
+
"RSS feeds per tag",
|
|
109
112
|
"CMS / external content-source adapters",
|
|
110
113
|
"hreflang / multi-language sites, base paths, sitemap indexes",
|
|
111
|
-
"MCP server",
|
|
112
|
-
"AVIF images and <picture> (images.optimize makes WebP copies offered with srcset)",
|
|
113
114
|
],
|
|
114
115
|
};
|
|
115
116
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vage new page <path>` and `vage new post <title>`: start a page with valid frontmatter.
|
|
3
|
+
* New files are drafts (`draft: true`), so placeholder text can't be published by
|
|
4
|
+
* accident: the dev server shows them, `vage build` skips them until the line is removed.
|
|
5
|
+
* Without a description, a comment marks where one goes (search engines then get the
|
|
6
|
+
* site's description, or nothing): vagejs never writes placeholder text that could publish.
|
|
7
|
+
*/
|
|
8
|
+
export interface NewOptions {
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Posts: the section folder under src/pages (default "blog"). */
|
|
12
|
+
under?: string;
|
|
13
|
+
/** Posts: YYYY-MM-DD (default: today, local time). */
|
|
14
|
+
date?: string;
|
|
15
|
+
/** Posts: only written when given; vagejs never invents an author. */
|
|
16
|
+
author?: string;
|
|
17
|
+
/** Posts: the file name, when the title has no Latin letters or digits to make one from. */
|
|
18
|
+
slug?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface Created {
|
|
21
|
+
file: string;
|
|
22
|
+
route: string;
|
|
23
|
+
draft: true;
|
|
24
|
+
}
|
|
25
|
+
/** "Getting Started: Part 2!" -> "getting-started-part-2" */
|
|
26
|
+
export declare function slugify(text: string): string;
|
|
27
|
+
export declare function createPage(root: string, kind: "page" | "post", name: string, options?: NewOptions): Created;
|
package/dist/cli/new.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { VageError } from "../core/errors.js";
|
|
4
|
+
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
|
|
5
|
+
/** "Getting Started: Part 2!" -> "getting-started-part-2" */
|
|
6
|
+
export function slugify(text) {
|
|
7
|
+
return text
|
|
8
|
+
.normalize("NFKD")
|
|
9
|
+
.replace(/[̀-ͯ]/g, "")
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "")
|
|
13
|
+
.slice(0, 80)
|
|
14
|
+
.replace(/-+$/, "");
|
|
15
|
+
}
|
|
16
|
+
const titleFromSlug = (slug) => {
|
|
17
|
+
const last = slug.split("/").at(-1);
|
|
18
|
+
return last === "index" ? "Overview" : last.replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
19
|
+
};
|
|
20
|
+
const today = () => {
|
|
21
|
+
const d = new Date();
|
|
22
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
23
|
+
};
|
|
24
|
+
/** YAML double-quoted scalars are JSON strings, so any title is written safely. */
|
|
25
|
+
const yaml = (value) => JSON.stringify(value);
|
|
26
|
+
export function createPage(root, kind, name, options = {}) {
|
|
27
|
+
const pagesDir = path.join(path.resolve(root), "src", "pages");
|
|
28
|
+
if (!fs.existsSync(pagesDir))
|
|
29
|
+
throw new VageError(`No src/pages folder in ${path.resolve(root)}. Run this inside a vagejs project.`);
|
|
30
|
+
// One line each: a line break would end up in the heading and fail the frontmatter schema.
|
|
31
|
+
for (const [label, value] of [["title", kind === "post" ? name : options.title], ["description", options.description], ["author", options.author]]) {
|
|
32
|
+
if (value !== undefined && /[\u0000-\u001f\u007f]/.test(value))
|
|
33
|
+
throw new VageError(`The ${label} must be one line of text, without line breaks or tabs.`);
|
|
34
|
+
}
|
|
35
|
+
let slug;
|
|
36
|
+
let title;
|
|
37
|
+
if (kind === "page") {
|
|
38
|
+
slug = name.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "");
|
|
39
|
+
if (!SLUG.test(slug))
|
|
40
|
+
throw new VageError(`"${name}" isn't a valid page path. Use lowercase letters, numbers and hyphens, with / between folders (e.g. pricing or docs/setup).`);
|
|
41
|
+
title = options.title?.trim() || titleFromSlug(slug);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
title = name.trim();
|
|
45
|
+
if (!title)
|
|
46
|
+
throw new VageError('Give the post a title, e.g. vage new post "Our first launch".');
|
|
47
|
+
const under = (options.under ?? "blog").replace(/^\/+|\/+$/g, "");
|
|
48
|
+
if (!SLUG.test(under))
|
|
49
|
+
throw new VageError(`"${options.under}" isn't a valid section folder. Use lowercase letters, numbers, hyphens and /.`);
|
|
50
|
+
const base = options.slug !== undefined ? options.slug : slugify(title);
|
|
51
|
+
if (!base)
|
|
52
|
+
throw new VageError(`Couldn't make a file name from "${name}". Add --slug <name> (lowercase letters, numbers and hyphens).`);
|
|
53
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(base))
|
|
54
|
+
throw new VageError(`"${base}" isn't a valid --slug. Use lowercase letters, numbers and hyphens.`);
|
|
55
|
+
slug = `${under}/${base}`;
|
|
56
|
+
}
|
|
57
|
+
if (options.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(options.date))
|
|
58
|
+
throw new VageError(`--date must be YYYY-MM-DD, not "${options.date}".`);
|
|
59
|
+
const route = slug === "index" ? "/" : `/${slug.replace(/(^|\/)index$/, "")}/`.replace(/\/+/g, "/");
|
|
60
|
+
const rel = path.join("src", "pages", `${slug}.md`);
|
|
61
|
+
const file = path.join(pagesDir, `${slug}.md`);
|
|
62
|
+
// A page at /x/ can come from x.md or x/index.md: refuse either, never overwrite.
|
|
63
|
+
const twin = slug.endsWith("/index") ? path.join(pagesDir, `${slug.slice(0, -"/index".length)}.md`) : path.join(pagesDir, slug, "index.md");
|
|
64
|
+
for (const existing of [file, twin]) {
|
|
65
|
+
if (fs.existsSync(existing))
|
|
66
|
+
throw new VageError(`${path.relative(path.resolve(root), existing)} already exists, so ${route} is taken. Pick another name, or edit that file.`);
|
|
67
|
+
}
|
|
68
|
+
const lines = ["---", `title: ${yaml(title)}`];
|
|
69
|
+
if (options.description?.trim())
|
|
70
|
+
lines.push(`description: ${yaml(options.description.trim())}`);
|
|
71
|
+
else
|
|
72
|
+
lines.push("# description: one or two sentences for search results (until then, the site's description is used)");
|
|
73
|
+
if (kind === "post") {
|
|
74
|
+
lines.push("type: article", `datePublished: ${options.date ?? today()}`);
|
|
75
|
+
if (options.author?.trim())
|
|
76
|
+
lines.push(`author: ${yaml(options.author.trim())}`);
|
|
77
|
+
}
|
|
78
|
+
lines.push("# Remove the next line when the page is ready to publish.", "draft: true", "---", "", `# ${title}`, "", "Write here.", "");
|
|
79
|
+
// Never write through a symlinked folder: it can point outside the project.
|
|
80
|
+
let existing = path.dirname(file);
|
|
81
|
+
while (!fs.existsSync(existing))
|
|
82
|
+
existing = path.dirname(existing);
|
|
83
|
+
const realPages = fs.realpathSync(pagesDir);
|
|
84
|
+
const realParent = fs.realpathSync(existing);
|
|
85
|
+
if (realParent !== realPages && !realParent.startsWith(realPages + path.sep))
|
|
86
|
+
throw new VageError(`${path.relative(path.resolve(root), existing)} leads outside src/pages (a symlink?). Create the page in a regular folder.`);
|
|
87
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
88
|
+
try {
|
|
89
|
+
fs.writeFileSync(file, lines.join("\n"), { flag: "wx" });
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (err.code === "EEXIST")
|
|
93
|
+
throw new VageError(`${rel} already exists, so ${route} is taken. Pick another name, or edit that file.`);
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
return { file: rel, route, draft: true };
|
|
97
|
+
}
|
|
@@ -16,6 +16,7 @@ export declare const frontmatterSchema: z.ZodObject<{
|
|
|
16
16
|
dateModified: z.ZodOptional<z.ZodPreprocess<z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>, unknown>>;
|
|
17
17
|
order: z.ZodOptional<z.ZodNumber>;
|
|
18
18
|
navTitle: z.ZodOptional<z.ZodString>;
|
|
19
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
19
20
|
}, z.core.$loose>;
|
|
20
21
|
export type Frontmatter = z.output<typeof frontmatterSchema>;
|
|
21
22
|
export interface MarkdownPage {
|
package/dist/content/markdown.js
CHANGED
|
@@ -30,6 +30,10 @@ export const frontmatterSchema = z.looseObject({
|
|
|
30
30
|
dateModified: isoDate.optional().describe("ISO date or datetime; also the sitemap <lastmod>."),
|
|
31
31
|
order: z.number().finite().optional().describe('Position in navigation lists (lower first). Used by collection(pages, { sort: "order" }) and tree().'),
|
|
32
32
|
navTitle: singleLine.min(1).optional().describe("Shorter title for menus and breadcrumbs. Defaults to the title."),
|
|
33
|
+
tags: z
|
|
34
|
+
.array(singleLine.regex(/[\p{L}\p{N}]/u, "each tag needs a letter or digit (it becomes the address /tags/<tag>/)"))
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('Topics, e.g. [SEO, AI]. Each tag gets a page at /tags/<tag>/ listing its pages (tags.pages); collection(pages, { tag: "SEO" }) picks them.'),
|
|
33
37
|
});
|
|
34
38
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
35
39
|
const marked = new Marked({ gfm: true });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Opt-in image pipeline (images.optimize): resized WebP copies of the images used in
|
|
3
|
-
* page content, offered to browsers with srcset
|
|
3
|
+
* page content, offered to browsers with srcset; with images.avif, AVIF copies too,
|
|
4
|
+
* offered first through <picture>. Needs the `sharp` package in the
|
|
4
5
|
* project. Results are cached in node_modules/.cache/vagejs/img, keyed by the file's
|
|
5
6
|
* content and the settings, so unchanged images are never processed twice.
|
|
6
7
|
*/
|
|
@@ -9,15 +10,18 @@ export interface ImageOptions {
|
|
|
9
10
|
widths: number[];
|
|
10
11
|
quality: number;
|
|
11
12
|
sizes: string;
|
|
13
|
+
avif?: boolean;
|
|
12
14
|
}
|
|
13
15
|
export interface Variants {
|
|
14
|
-
/** "url 480w, url 960w" */
|
|
16
|
+
/** "url 480w, url 960w" (WebP) */
|
|
15
17
|
srcset: string;
|
|
18
|
+
/** The same widths as AVIF, when images.avif is on. */
|
|
19
|
+
avifSrcset?: string;
|
|
16
20
|
/** Published path (e.g. "_vage/img/photo-ab12cd34-480.webp") -> cached file on disk. */
|
|
17
21
|
files: Map<string, string>;
|
|
18
22
|
}
|
|
19
|
-
/** Turns an image into WebP at a given width. Injected in tests; sharp otherwise. */
|
|
20
|
-
export type Resize = (input: Buffer, width: number, quality: number) => Promise<Buffer>;
|
|
23
|
+
/** Turns an image into WebP (or AVIF) at a given width. Injected in tests; sharp otherwise. */
|
|
24
|
+
export type Resize = (input: Buffer, width: number, quality: number, format?: "webp" | "avif") => Promise<Buffer>;
|
|
21
25
|
export declare const IMAGE_DIR = "_vage/img";
|
|
22
26
|
export declare function cacheDir(root: string): string;
|
|
23
27
|
/** Loads sharp from the project (not from vagejs), with a clear message if it's missing. */
|