vagejs 0.3.4 → 0.4.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 +19 -3
- package/dist/audit/budgets.js +21 -1
- package/dist/cli/index.js +35 -0
- package/dist/cli/info.js +1 -3
- package/dist/cli/new.d.ts +27 -0
- package/dist/cli/new.js +97 -0
- package/dist/content/optimize.d.ts +8 -4
- package/dist/content/optimize.js +35 -22
- package/dist/content/sanitize.d.ts +8 -8
- package/dist/content/sanitize.js +39 -8
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.js +2 -1
- package/dist/core/render.js +1 -1
- package/dist/deploy/cloudflare-workers.js +5 -2
- package/dist/deploy/github-pages.d.ts +7 -0
- package/dist/deploy/github-pages.js +20 -0
- package/dist/dev/guard.js +1 -1
- package/dist/dev/server.js +2 -2
- package/dist/mcp/server.d.ts +23 -0
- package/dist/mcp/server.js +80 -0
- package/package.json +2 -1
- package/templates/starter/AGENTS.md +9 -5
- package/templates/starter/package.json +1 -1
- package/templates/starter/vage.config.ts +2 -2
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)
|
|
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
25
|
- **Safe by default**: page content is sanitized; secrets, build output and force-pushes are refused; Cloudflare 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 and Cloudflare Workers built in; any static host (Vercel, Netlify) works with `npm run build` and the `dist` folder.
|
|
23
28
|
|
|
24
29
|
## Quick start
|
|
25
30
|
|
|
@@ -68,7 +73,7 @@ Our plans start at …
|
|
|
68
73
|
|
|
69
74
|
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
75
|
|
|
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`.
|
|
76
|
+
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
77
|
|
|
73
78
|
### Navigation, feeds and AI extras
|
|
74
79
|
|
|
@@ -173,6 +178,15 @@ npx vage info --json # commands, options, config and frontmatter JSON Schemas
|
|
|
173
178
|
npx vage check --json # findings with file, rule and fix hints
|
|
174
179
|
```
|
|
175
180
|
|
|
181
|
+
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:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npm install --save-dev @modelcontextprotocol/sdk
|
|
185
|
+
claude mcp add --transport stdio --scope project vagejs -- npx -p vagejs vage mcp # Claude Code, in the project folder
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
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.
|
|
189
|
+
|
|
176
190
|
## Command reference
|
|
177
191
|
|
|
178
192
|
```bash
|
|
@@ -197,7 +211,7 @@ vage info [--json] # commands + config/frontmatte
|
|
|
197
211
|
|
|
198
212
|
## Planned, not implemented
|
|
199
213
|
|
|
200
|
-
Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths, sitemap indexes
|
|
214
|
+
Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths, sitemap indexes.
|
|
201
215
|
|
|
202
216
|
## Security
|
|
203
217
|
|
|
@@ -225,6 +239,8 @@ Also protect the accounts around the site: two-factor authentication on GitHub,
|
|
|
225
239
|
|
|
226
240
|
## Contributing
|
|
227
241
|
|
|
242
|
+
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).
|
|
243
|
+
|
|
228
244
|
```bash
|
|
229
245
|
npm install
|
|
230
246
|
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/cli/index.js
CHANGED
|
@@ -142,6 +142,35 @@ cli
|
|
|
142
142
|
if (opts.open && target)
|
|
143
143
|
openBrowser(target);
|
|
144
144
|
}));
|
|
145
|
+
cli
|
|
146
|
+
.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')
|
|
147
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
148
|
+
.option("--title <text>", "Page title (pages; default from the path)")
|
|
149
|
+
.option("--description <text>", "One or two sentences for search results")
|
|
150
|
+
.option("--under <folder>", "Posts: section folder under src/pages (default: blog)")
|
|
151
|
+
.option("--date <YYYY-MM-DD>", "Posts: datePublished (default: today)")
|
|
152
|
+
.option("--author <name>", "Posts: author (only written when given)")
|
|
153
|
+
.option("--slug <name>", "Posts: file name, for titles without Latin letters or digits")
|
|
154
|
+
.option("--json", "Print machine-readable JSON")
|
|
155
|
+
.action((kind, name, opts) => run(opts, async () => {
|
|
156
|
+
if (kind !== "page" && kind !== "post")
|
|
157
|
+
throw new VageError(`Unknown kind "${kind}". Use vage new page <path> or vage new post "<title>".`);
|
|
158
|
+
const { createPage } = await import("./new.js");
|
|
159
|
+
// cac turns numeric values (--title 123) into numbers, so every option is read as text.
|
|
160
|
+
const text = (v) => (v === undefined ? undefined : String(v));
|
|
161
|
+
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) });
|
|
162
|
+
if (opts.json)
|
|
163
|
+
return print({ ok: true, ...created });
|
|
164
|
+
console.log(`Created ${created.file} (draft) for ${created.route}`);
|
|
165
|
+
console.log("Preview it with npm start (drafts show there); remove `draft: true` to publish it, then run npm run check.");
|
|
166
|
+
}));
|
|
167
|
+
cli
|
|
168
|
+
.command("mcp", "Run an MCP server over stdio so AI assistants can check this site (read-only tools; needs @modelcontextprotocol/sdk)")
|
|
169
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
170
|
+
.action((opts) => run({}, async () => {
|
|
171
|
+
const { startMcpServer } = await import("../mcp/server.js");
|
|
172
|
+
await startMcpServer(opts.root, { version, info: () => frameworkInfo(cli, version) });
|
|
173
|
+
}));
|
|
145
174
|
cli
|
|
146
175
|
.command("info", "Describe commands, config and frontmatter (use --json for agents)")
|
|
147
176
|
.option("--json", "Print machine-readable JSON")
|
|
@@ -197,6 +226,12 @@ cli
|
|
|
197
226
|
throw new VageError(`Unknown --target "${target}". Use github-pages, cloudflare-workers or cloudflare-pages.`);
|
|
198
227
|
const { publish } = await import("../deploy/publish.js");
|
|
199
228
|
const pages = await import("../deploy/github-pages.js");
|
|
229
|
+
// Stop before pushing a site GitHub Pages would serve from a subfolder (its links would break).
|
|
230
|
+
const { gitRaw } = await import("../deploy/git.js");
|
|
231
|
+
const origin = opts.remote ?? (await gitRaw(path.resolve(opts.root), ["remote", "get-url", "origin"]).catch(() => undefined))?.stdout?.trim();
|
|
232
|
+
const subfolder = pages.pagesSubfolderProblem(config.site, origin);
|
|
233
|
+
if (subfolder)
|
|
234
|
+
throw new VageError(subfolder);
|
|
200
235
|
const result = await publish(opts.root, {
|
|
201
236
|
...opts,
|
|
202
237
|
extraFiles: (branch) => ({ [pages.PAGES_WORKFLOW]: pages.pagesWorkflow(branch) }),
|
package/dist/cli/info.js
CHANGED
|
@@ -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: {
|
|
@@ -108,8 +108,6 @@ export function frameworkInfo(cli, version) {
|
|
|
108
108
|
"Netlify and Vercel deploy adapters",
|
|
109
109
|
"CMS / external content-source adapters",
|
|
110
110
|
"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
111
|
],
|
|
114
112
|
};
|
|
115
113
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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. */
|
package/dist/content/optimize.js
CHANGED
|
@@ -23,7 +23,11 @@ export async function sharpResize(root) {
|
|
|
23
23
|
}
|
|
24
24
|
const sharp = (await import(pathToFileURL(resolved).href)).default;
|
|
25
25
|
// rotate() applies EXIF orientation first, so phone photos aren't served sideways.
|
|
26
|
-
|
|
26
|
+
// AVIF uses sharp's own default quality: its scale differs from WebP's, so images.quality would overshoot.
|
|
27
|
+
return (input, width, quality, format = "webp") => {
|
|
28
|
+
const resized = sharp(input).rotate().resize({ width, withoutEnlargement: true });
|
|
29
|
+
return (format === "avif" ? resized.avif() : resized.webp({ quality })).toBuffer();
|
|
30
|
+
};
|
|
27
31
|
}
|
|
28
32
|
/**
|
|
29
33
|
* WebP variants for one site-path image in public/. Undefined for remote images,
|
|
@@ -61,30 +65,39 @@ export async function imageVariants(src, root, options, resize) {
|
|
|
61
65
|
const dir = cacheDir(root);
|
|
62
66
|
fs.mkdirSync(dir, { recursive: true });
|
|
63
67
|
const files = new Map();
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
const srcsets = {};
|
|
69
|
+
// Real widths, read from the WebP files; AVIF copies are resized the same way (vagejs can't read AVIF sizes).
|
|
70
|
+
const realWidth = new Map();
|
|
71
|
+
for (const format of options.avif ? ["webp", "avif"] : ["webp"]) {
|
|
72
|
+
const entries = [];
|
|
73
|
+
for (const w of widths) {
|
|
74
|
+
const name = `${base}-${hash}-${w}.${format}`;
|
|
75
|
+
const cached = path.join(dir, name);
|
|
76
|
+
if (!fs.existsSync(cached)) {
|
|
77
|
+
const tmp = `${cached}.${process.pid}.tmp`;
|
|
78
|
+
let data;
|
|
79
|
+
try {
|
|
80
|
+
data = await resize(input, w, options.quality, format);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
throw new VageError(`Couldn't make a ${format === "avif" ? "AVIF" : "WebP"} copy of ${src}: ${err.message}. Replace the file, or turn off ${format === "avif" ? "images.avif" : "images.optimize"}.`);
|
|
84
|
+
}
|
|
85
|
+
fs.writeFileSync(tmp, data);
|
|
86
|
+
fs.renameSync(tmp, cached);
|
|
73
87
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
// Label with the file's real width (rotation can change it); publish each width once.
|
|
89
|
+
const actual = format === "webp" ? (imageSize(cached)?.width ?? w) : (realWidth.get(w) ?? w);
|
|
90
|
+
if (format === "webp")
|
|
91
|
+
realWidth.set(w, actual);
|
|
92
|
+
if (entries.some((e) => e.endsWith(` ${actual}w`)))
|
|
93
|
+
continue;
|
|
94
|
+
files.set(`${IMAGE_DIR}/${name}`, cached);
|
|
95
|
+
entries.push(`/${IMAGE_DIR}/${name} ${actual}w`);
|
|
79
96
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (entries.some((e) => e.endsWith(` ${actual}w`)))
|
|
83
|
-
continue;
|
|
84
|
-
files.set(`${IMAGE_DIR}/${name}`, cached);
|
|
85
|
-
entries.push(`/${IMAGE_DIR}/${name} ${actual}w`);
|
|
97
|
+
if (entries.length)
|
|
98
|
+
srcsets[format] = entries.join(", ");
|
|
86
99
|
}
|
|
87
|
-
return
|
|
100
|
+
return srcsets.webp ? { srcset: srcsets.webp, avifSrcset: srcsets.avif, files } : undefined;
|
|
88
101
|
}
|
|
89
102
|
/** Image sources used in rendered Markdown, read with an HTML parser (so entities decode like the DOM's). */
|
|
90
103
|
export function contentImageSources(html) {
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** Optimized copies for one image source: WebP srcset and sizes, plus AVIF when images.avif is on. */
|
|
2
|
+
export interface ImageVariant {
|
|
3
|
+
srcset: string;
|
|
4
|
+
sizes: string;
|
|
5
|
+
avifSrcset?: string;
|
|
6
|
+
}
|
|
1
7
|
/**
|
|
2
8
|
* Sanitizes rendered Markdown HTML. Page content may be written by AI agents,
|
|
3
9
|
* contributors or (later) CMS sources, so scripts, event handlers, javascript:
|
|
@@ -11,13 +17,7 @@
|
|
|
11
17
|
export declare function sanitizeHtml(html: string, options?: {
|
|
12
18
|
publicDir?: string;
|
|
13
19
|
inlineStyles?: boolean;
|
|
14
|
-
variants?: Map<string,
|
|
15
|
-
srcset: string;
|
|
16
|
-
sizes: string;
|
|
17
|
-
}>;
|
|
20
|
+
variants?: Map<string, ImageVariant>;
|
|
18
21
|
}): Promise<string>;
|
|
19
22
|
/** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
|
|
20
|
-
export declare function enhanceTrustedHtml(html: string, publicDir: string, variants?: Map<string,
|
|
21
|
-
srcset: string;
|
|
22
|
-
sizes: string;
|
|
23
|
-
}>): Promise<string>;
|
|
23
|
+
export declare function enhanceTrustedHtml(html: string, publicDir: string, variants?: Map<string, ImageVariant>): Promise<string>;
|
package/dist/content/sanitize.js
CHANGED
|
@@ -32,15 +32,24 @@ export async function sanitizeHtml(html, options = {}) {
|
|
|
32
32
|
if (publicDir) {
|
|
33
33
|
purifier.addHook("afterSanitizeAttributes", hook);
|
|
34
34
|
}
|
|
35
|
+
const config = {
|
|
36
|
+
// font/marquee/center: legacy ways to hide or disguise text (white-on-white, 1px scrollers).
|
|
37
|
+
FORBID_TAGS: ["style", "form", "input", "button", "textarea", "select", "option", "base", "link", "meta", "object", "embed", "font", "marquee", "center"],
|
|
38
|
+
ALLOW_DATA_ATTR: true,
|
|
39
|
+
// Inline styles are the usual way to hide injected links (display:none, off-screen); layouts style pages.
|
|
40
|
+
// xlink:href: SVG links, which would slip past link checks.
|
|
41
|
+
FORBID_ATTR: options.inlineStyles ? ["xlink:href"] : ["style", "xlink:href"],
|
|
42
|
+
};
|
|
35
43
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
if (![...(options.variants?.values() ?? [])].some((v) => v.avifSrcset))
|
|
45
|
+
return purifier.sanitize(html, config);
|
|
46
|
+
// AVIF: wrap images in <picture> on DOMPurify's own output tree, then serialize once.
|
|
47
|
+
// (Editing sanitized HTML as a string and parsing it again could change its meaning.)
|
|
48
|
+
const fragment = purifier.sanitize(html, { ...config, RETURN_DOM_FRAGMENT: true });
|
|
49
|
+
addAvifSources(fragment, options.variants);
|
|
50
|
+
const container = fragment.ownerDocument.createElement("div");
|
|
51
|
+
container.appendChild(fragment);
|
|
52
|
+
return container.innerHTML;
|
|
44
53
|
}
|
|
45
54
|
finally {
|
|
46
55
|
if (publicDir)
|
|
@@ -53,5 +62,27 @@ export async function enhanceTrustedHtml(html, publicDir, variants) {
|
|
|
53
62
|
const template = new jsdom.JSDOM("").window.document.createElement("template");
|
|
54
63
|
template.innerHTML = html;
|
|
55
64
|
template.content.querySelectorAll("img").forEach((img, i) => enhanceImage(img, publicDir, i === 0, variants?.get(img.getAttribute("src") ?? "")));
|
|
65
|
+
if (variants)
|
|
66
|
+
addAvifSources(template.content, variants);
|
|
56
67
|
return template.innerHTML;
|
|
57
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Offers AVIF first: <picture><source type="image/avif" srcset sizes><img …></picture>.
|
|
71
|
+
* Browsers without AVIF use the <img> and its WebP srcset. Only images vagejs gave a
|
|
72
|
+
* srcset are wrapped (an author's own srcset or <picture> is left alone).
|
|
73
|
+
*/
|
|
74
|
+
function addAvifSources(root, variants) {
|
|
75
|
+
for (const img of [...root.querySelectorAll("img")]) {
|
|
76
|
+
const v = variants.get(img.getAttribute("src") ?? "");
|
|
77
|
+
if (!v?.avifSrcset || img.getAttribute("srcset") !== v.srcset || img.parentElement?.nodeName === "PICTURE")
|
|
78
|
+
continue;
|
|
79
|
+
const doc = img.ownerDocument;
|
|
80
|
+
const picture = doc.createElement("picture");
|
|
81
|
+
const source = doc.createElement("source");
|
|
82
|
+
source.setAttribute("type", "image/avif");
|
|
83
|
+
source.setAttribute("srcset", v.avifSrcset);
|
|
84
|
+
source.setAttribute("sizes", img.getAttribute("sizes") ?? v.sizes);
|
|
85
|
+
img.replaceWith(picture);
|
|
86
|
+
picture.append(source, img);
|
|
87
|
+
}
|
|
88
|
+
}
|
package/dist/core/config.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare const configSchema: z.ZodObject<{
|
|
|
9
9
|
optimize: z.ZodDefault<z.ZodBoolean>;
|
|
10
10
|
widths: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
|
|
11
11
|
quality: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
avif: z.ZodDefault<z.ZodBoolean>;
|
|
12
13
|
sizes: z.ZodDefault<z.ZodString>;
|
|
13
14
|
}, z.core.$strict>>;
|
|
14
15
|
markdown: z.ZodDefault<z.ZodObject<{
|
package/dist/core/config.js
CHANGED
|
@@ -190,9 +190,10 @@ export const configSchema = z.strictObject({
|
|
|
190
190
|
optimize: z.boolean().default(false).describe('Make resized WebP copies of images in page content and offer them with srcset. Needs "sharp" in the project (npm install --save-dev sharp).'),
|
|
191
191
|
widths: z.array(z.number().int().min(16).max(8000)).min(1).max(8).default([480, 960, 1600]).describe("Widths to generate (never larger than the original)."),
|
|
192
192
|
quality: z.number().int().min(1).max(100).default(75).describe("WebP quality."),
|
|
193
|
+
avif: z.boolean().default(false).describe("Also make AVIF copies (usually smaller than WebP, slower to make) and offer both with <picture>: browsers that support AVIF use it, others use WebP."),
|
|
193
194
|
sizes: z.string().regex(/^[^<>"]+$/).default("(max-width: 48rem) 100vw, 48rem").describe('The sizes attribute: how wide images display, so browsers pick the right file.'),
|
|
194
195
|
})
|
|
195
|
-
.default({ optimize: false, widths: [480, 960, 1600], quality: 75, sizes: "(max-width: 48rem) 100vw, 48rem" }),
|
|
196
|
+
.default({ optimize: false, widths: [480, 960, 1600], quality: 75, avif: false, sizes: "(max-width: 48rem) 100vw, 48rem" }),
|
|
196
197
|
markdown: z
|
|
197
198
|
.strictObject({
|
|
198
199
|
html: z
|
package/dist/core/render.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function renderPage(options) {
|
|
|
23
23
|
const v = await imageVariants(src, root, site.images, resize);
|
|
24
24
|
if (!v)
|
|
25
25
|
continue;
|
|
26
|
-
variants.set(src, { srcset: v.srcset, sizes: site.images.sizes });
|
|
26
|
+
variants.set(src, { srcset: v.srcset, sizes: site.images.sizes, avifSrcset: v.avifSrcset });
|
|
27
27
|
for (const [k, f] of v.files)
|
|
28
28
|
assets.set(k, f);
|
|
29
29
|
}
|
|
@@ -178,7 +178,8 @@ export async function deployCloudflareWorkers(root, options) {
|
|
|
178
178
|
output = out(upload);
|
|
179
179
|
if (upload.code !== 0) {
|
|
180
180
|
// Only a Worker that doesn't exist yet gets a full deploy (which also attaches routes).
|
|
181
|
-
|
|
181
|
+
// wrangler 4.152 says "...a new version of a Worker that does not yet exist" (no code).
|
|
182
|
+
if (!/\b10007\b|worker[^\n]*(?:does not (?:yet )?exist|not found)/i.test(output))
|
|
182
183
|
return { status: "failure", worker, reason: failReason(upload.code, output) };
|
|
183
184
|
mode = "first-deploy";
|
|
184
185
|
const first = await wrangler("deploy");
|
|
@@ -211,7 +212,9 @@ export async function deployCloudflareWorkers(root, options) {
|
|
|
211
212
|
return { status: "success", worker, mode, versionId, pageUrl };
|
|
212
213
|
await sleep(3000);
|
|
213
214
|
}
|
|
214
|
-
|
|
215
|
+
// `site` may not point at this Worker yet (a new domain, or a typo): say where the Worker itself answers.
|
|
216
|
+
const own = workersDev && workersDev !== pageUrl ? ` The Worker itself is at ${workersDev}. If ${pageUrl} is your own domain, attach it to the Worker (routes or a custom domain in your wrangler config).` : "";
|
|
217
|
+
return { status: "unconfirmed", worker, mode, versionId, pageUrl, reason: `Deployed, but ${pageUrl} did not respond with 200 yet. Check it in a minute.${own}` };
|
|
215
218
|
}
|
|
216
219
|
finally {
|
|
217
220
|
if (tempDir)
|
|
@@ -31,3 +31,10 @@ export declare function confirmPagesDeployment(cwd: string, sha: string, options
|
|
|
31
31
|
sleep?: (ms: number) => Promise<void>;
|
|
32
32
|
findRunAttempts?: number;
|
|
33
33
|
}): Promise<DeployStatus>;
|
|
34
|
+
/**
|
|
35
|
+
* GitHub Pages serves a repository named anything but <owner>.github.io from a subfolder
|
|
36
|
+
* (https://owner.github.io/repo/). vagejs sites need the root of a domain (links like
|
|
37
|
+
* /about/ would point outside the site), so that only works with a custom domain.
|
|
38
|
+
* Returns the problem to show before anything is pushed, or undefined when it's fine.
|
|
39
|
+
*/
|
|
40
|
+
export declare function pagesSubfolderProblem(site: string | undefined, remote: string | undefined): string | undefined;
|
|
@@ -99,3 +99,23 @@ export async function confirmPagesDeployment(cwd, sha, options = {}) {
|
|
|
99
99
|
const pages = await run("gh", ["api", "repos/{owner}/{repo}/pages", "--jq", ".html_url"], cwd);
|
|
100
100
|
return { status: "success", runUrl, pageUrl: pages.code === 0 ? pages.stdout.trim() || undefined : undefined };
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* GitHub Pages serves a repository named anything but <owner>.github.io from a subfolder
|
|
104
|
+
* (https://owner.github.io/repo/). vagejs sites need the root of a domain (links like
|
|
105
|
+
* /about/ would point outside the site), so that only works with a custom domain.
|
|
106
|
+
* Returns the problem to show before anything is pushed, or undefined when it's fine.
|
|
107
|
+
*/
|
|
108
|
+
export function pagesSubfolderProblem(site, remote) {
|
|
109
|
+
const m = remote && /(?:^|[@/])github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(remote.trim());
|
|
110
|
+
if (!m || !site)
|
|
111
|
+
return undefined;
|
|
112
|
+
const [, owner, repo] = m;
|
|
113
|
+
if (repo.toLowerCase() === `${owner}.github.io`.toLowerCase())
|
|
114
|
+
return undefined;
|
|
115
|
+
if (!/\.github\.io$/i.test(new URL(site).hostname))
|
|
116
|
+
return undefined; // a custom domain serves the root
|
|
117
|
+
return (`GitHub Pages would serve this repository at https://${owner.toLowerCase()}.github.io/${repo}/, a subfolder, ` +
|
|
118
|
+
`but a vagejs site must be served from the root of a domain: links like /about/ would point outside the site. Either:\n` +
|
|
119
|
+
` - use your own domain: set site to it in vage.config.ts and add it in the repository under Settings → Pages → Custom domain, or\n` +
|
|
120
|
+
` - publish from a repository named ${owner}.github.io (set site to https://${owner.toLowerCase()}.github.io).`);
|
|
121
|
+
}
|
package/dist/dev/guard.js
CHANGED
|
@@ -46,7 +46,7 @@ export function allowed(url, publicDir, allowedFiles) {
|
|
|
46
46
|
if (allowedFiles.has(pathname))
|
|
47
47
|
return true;
|
|
48
48
|
// Optimized images (images.optimize), served from the cache by the dev server.
|
|
49
|
-
if (/^\/_vage\/img\/[\w-]+\.webp$/.test(pathname))
|
|
49
|
+
if (/^\/_vage\/img\/[\w-]+\.(webp|avif)$/.test(pathname))
|
|
50
50
|
return true;
|
|
51
51
|
// Generated files (seo.feeds, seo.markdownCopies) never exist on disk in dev. If a real file
|
|
52
52
|
// sits at that path, it's a project source (e.g. /src/pages/about.md): Vite would serve it, so refuse.
|
package/dist/dev/server.js
CHANGED
|
@@ -48,12 +48,12 @@ export async function startDevServer(root, options = {}) {
|
|
|
48
48
|
const urlPath = req.url ?? "/";
|
|
49
49
|
const pathname = urlPath.split(/[?#]/)[0];
|
|
50
50
|
try {
|
|
51
|
-
const optimized = /^\/_vage\/img\/([\w-]+\.webp)$/.exec(pathname);
|
|
51
|
+
const optimized = /^\/_vage\/img\/([\w-]+\.(webp|avif))$/.exec(pathname);
|
|
52
52
|
if (optimized) {
|
|
53
53
|
const file = path.join(cacheDir(root), optimized[1]);
|
|
54
54
|
if (!fs.existsSync(file))
|
|
55
55
|
return send(res, 404, plainPage("404 — Not found", `No optimized image at ${pathname}`));
|
|
56
|
-
res.setHeader("Content-Type",
|
|
56
|
+
res.setHeader("Content-Type", `image/${optimized[2]}`);
|
|
57
57
|
return res.end(fs.readFileSync(file));
|
|
58
58
|
}
|
|
59
59
|
const isFeed = /^\/(?:[\w.-]+\/)*feed\.xml$/.test(pathname) && !pathname.includes("..");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Published pages and drafts, without writing anything. */
|
|
2
|
+
export declare function listPages(root: string): Promise<{
|
|
3
|
+
pages: {
|
|
4
|
+
route: string;
|
|
5
|
+
source: string;
|
|
6
|
+
title: string;
|
|
7
|
+
description: string | undefined;
|
|
8
|
+
layout: string;
|
|
9
|
+
noindex: boolean;
|
|
10
|
+
}[];
|
|
11
|
+
drafts: string[];
|
|
12
|
+
errors: import("../core/site.js").PageError[];
|
|
13
|
+
}>;
|
|
14
|
+
/**
|
|
15
|
+
* `vage mcp`: an MCP server over stdio for the project at `root`. Every tool is
|
|
16
|
+
* read-only; approving links and deploying stay with people, through the CLI.
|
|
17
|
+
* stdout carries the protocol, so anything else that prints goes to stderr.
|
|
18
|
+
*/
|
|
19
|
+
export declare function startMcpServer(root: string, options: {
|
|
20
|
+
version: string;
|
|
21
|
+
info: () => unknown;
|
|
22
|
+
transport?: unknown;
|
|
23
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { check } from "../checks/check.js";
|
|
5
|
+
import { linkStatus } from "../checks/links.js";
|
|
6
|
+
import { VageError } from "../core/errors.js";
|
|
7
|
+
import { createBuildLoader } from "../core/loader.js";
|
|
8
|
+
import { renderSite } from "../core/site.js";
|
|
9
|
+
/** The MCP SDK is optional: only people who run `vage mcp` install it. */
|
|
10
|
+
const SDK = "@modelcontextprotocol/sdk";
|
|
11
|
+
const INSTALL = `vage mcp needs the "${SDK}" package. Install it in your project with: npm install --save-dev ${SDK}`;
|
|
12
|
+
/** Loads the SDK from the project first, then from wherever vagejs itself is installed. */
|
|
13
|
+
async function loadSdk(root) {
|
|
14
|
+
for (const from of [path.join(root, "package.json"), import.meta.url]) {
|
|
15
|
+
try {
|
|
16
|
+
const req = createRequire(from);
|
|
17
|
+
const mcp = await import(pathToFileURL(req.resolve(`${SDK}/server/mcp.js`)).href);
|
|
18
|
+
const stdio = await import(pathToFileURL(req.resolve(`${SDK}/server/stdio.js`)).href);
|
|
19
|
+
return { McpServer: mcp.McpServer, StdioServerTransport: stdio.StdioServerTransport };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// try the next location
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
throw new VageError(INSTALL);
|
|
26
|
+
}
|
|
27
|
+
/** Published pages and drafts, without writing anything. */
|
|
28
|
+
export async function listPages(root) {
|
|
29
|
+
const loader = await createBuildLoader(root);
|
|
30
|
+
try {
|
|
31
|
+
const model = await renderSite(root, loader.load, { collectErrors: true });
|
|
32
|
+
return {
|
|
33
|
+
pages: model.pages.map((p) => ({
|
|
34
|
+
route: p.route,
|
|
35
|
+
source: p.source,
|
|
36
|
+
title: p.frontmatter.title,
|
|
37
|
+
description: p.frontmatter.description,
|
|
38
|
+
layout: p.frontmatter.layout ?? "default",
|
|
39
|
+
noindex: Boolean(p.meta.noindex),
|
|
40
|
+
})),
|
|
41
|
+
drafts: model.skippedDrafts,
|
|
42
|
+
errors: model.pageErrors,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await loader.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
50
|
+
const text = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
51
|
+
/** Runs a tool; a failure (a broken layout, an invalid config) becomes a readable error result, without terminal colors. */
|
|
52
|
+
const tool = (fn) => async () => {
|
|
53
|
+
try {
|
|
54
|
+
return text(await fn());
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
// eslint-disable-next-line no-control-regex
|
|
58
|
+
const message = String(err?.message ?? err).replace(/\u001b\[[0-9;]*m/g, "");
|
|
59
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* `vage mcp`: an MCP server over stdio for the project at `root`. Every tool is
|
|
64
|
+
* read-only; approving links and deploying stay with people, through the CLI.
|
|
65
|
+
* stdout carries the protocol, so anything else that prints goes to stderr.
|
|
66
|
+
*/
|
|
67
|
+
export async function startMcpServer(root, options) {
|
|
68
|
+
root = path.resolve(root);
|
|
69
|
+
const sdk = await loadSdk(root);
|
|
70
|
+
if (!options.transport)
|
|
71
|
+
console.log = console.info = (...args) => console.error(...args);
|
|
72
|
+
const server = new sdk.McpServer({ name: "vagejs", version: options.version }, {
|
|
73
|
+
instructions: "Tools for a vagejs website project. Use vage_info for commands, settings and page fields; vage_check after every change (fix every error it reports; each finding names the file); vage_pages to see what exists; vage_links for outbound domains. Never approve link domains or deploy without the site owner: those stay CLI commands (npx vage links approve, npx vage deploy).",
|
|
74
|
+
});
|
|
75
|
+
server.registerTool("vage_info", { title: "Describe vagejs", description: "Commands, configuration options and page frontmatter fields, with their JSON Schemas (the same as `vage info --json`).", annotations: READ_ONLY }, tool(async () => options.info()));
|
|
76
|
+
server.registerTool("vage_check", { title: "Check the site", description: "Checks every page for broken links, missing titles and descriptions, structured data, accessibility basics and unapproved outbound links (the same as `vage check --json`). Writes nothing.", annotations: READ_ONLY }, tool(() => check(root)));
|
|
77
|
+
server.registerTool("vage_pages", { title: "List pages", description: "Every published page (route, source file, title, description, layout, noindex), the drafts that are skipped, and pages that fail to render.", annotations: READ_ONLY }, tool(() => listPages(root)));
|
|
78
|
+
server.registerTool("vage_links", { title: "List outbound domains", description: "External domains the site links to, which pages link there, and whether each is approved in links.lock.json. Only the site owner approves new domains.", annotations: READ_ONLY }, tool(() => linkStatus(root)));
|
|
79
|
+
await server.connect(options.transport ?? new sdk.StdioServerTransport());
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vagejs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A free static site generator for AI website development: Markdown pages with built-in SEO, llms.txt, checks, spam protection and safe deploys.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"static-site-generator",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@anthropic-ai/sdk": "^1.1.0",
|
|
60
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
60
61
|
"@types/jsdom": "^28.0.3",
|
|
61
62
|
"@types/node": "^26.6.5",
|
|
62
63
|
"@types/turndown": "^5.0.6",
|
|
@@ -20,14 +20,16 @@ Run `npm run check` after every change and fix all errors before finishing.
|
|
|
20
20
|
|
|
21
21
|
`npx vage info --json` prints every command and option plus JSON Schemas for `vage.config.ts` and page frontmatter. Prefer it over guessing.
|
|
22
22
|
|
|
23
|
+
**MCP server (optional):** `npx vage mcp` runs an MCP server over stdio with four read-only tools: `vage_info`, `vage_check`, `vage_pages` and `vage_links`. It needs `npm install --save-dev @modelcontextprotocol/sdk` in the project. For Claude Code, run in the project folder: `claude mcp add --transport stdio --scope project vagejs -- npx -p vagejs vage mcp` (saved in `.mcp.json`). 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 are not MCP tools; they stay CLI commands the site owner approves. Layouts must not write to `process.stdout` directly: in MCP mode stdout carries the protocol (vagejs moves `console.log` to stderr).
|
|
24
|
+
|
|
23
25
|
## Recipes
|
|
24
26
|
|
|
25
|
-
- **Add a page:**
|
|
26
|
-
- **Add a blog post:** `src/pages/blog/<slug>.md
|
|
27
|
+
- **Add a page:** `npx vage new page <slug>` (folders allowed: `docs/setup`; options `--title`, `--description`) creates `src/pages/<slug>.md` as a draft with valid frontmatter, or create the file yourself with `title` and `description`. Link to it as `/<slug>/` (trailing slash). Remove `draft: true` when it's ready, then run `npm run check`.
|
|
28
|
+
- **Add a blog post:** `npx vage new post "<Title>"` creates `src/pages/blog/<slug>.md` as a draft with `type: article` and today's `datePublished` (`--date`, `--under <folder>`, and `--author` only with a real name from the owner). It appears on `/blog/` automatically (`src/layouts/blog.ts`) once `draft: true` is removed.
|
|
27
29
|
- **Add a layout:** `src/layouts/<name>.ts`, then `layout: <name>` in frontmatter. Keep `<html lang>`, one `<title>` and `</head>`.
|
|
28
30
|
- **List pages (blog index, "latest posts", a sidebar):** in a layout, `collection(pages, { under: "/blog/", sort: "date" })` from `vagejs`. Never keep a hand-written list of pages.
|
|
29
31
|
- **Give a layout its own frontmatter fields:** `export const frontmatter = z.object({ ... })` in the layout (`import { z } from "vagejs"`). Pages using it are then checked strictly: unknown or mistyped fields fail the build.
|
|
30
|
-
- **Add an image:** put it in `public/images/` and write ``. Width and height are filled in from the file; write real alt text. For resized WebP copies, the owner can turn on `images: { optimize: true }` (needs `npm install --save-dev sharp`)
|
|
32
|
+
- **Add an image:** put it in `public/images/` and write ``. Width and height are filled in from the file; write real alt text. For resized WebP copies, the owner can turn on `images: { optimize: true }` (needs `npm install --save-dev sharp`), and `avif: true` for AVIF copies offered first through `<picture>`. Only images in page content are optimized, not images a layout writes.
|
|
31
33
|
- **Order a menu or sidebar:** set `order:` (lower first) and optionally a short `navTitle:` in each page's frontmatter (shown as `p.label`), then build the menu in a layout with `tree(pages, "/docs/")`. For previous/next links in the same order use `neighbors(flatten(tree(pages, "/docs/")), page.route)`. For a flat section, `collection(pages, { under: "/docs/", sort: "order", includeIndex: true })` works too. `breadcrumbs(pages, page.route)` gives the trail from the home page.
|
|
32
34
|
- **Add an RSS feed for the blog:** `seo: { feeds: [{ under: "/blog/" }] }` in `vage.config.ts` writes `/blog/feed.xml`, newest `datePublished` first (undated posts last, without a date).
|
|
33
35
|
- **Help AI tools read the site:** `seo: { markdownCopies: true }` publishes a Markdown copy of every listed page (`/docs/cli/` becomes `/docs/cli.md`), converted from the page's `<main>`, so it contains exactly what the page shows (including content a layout builds from frontmatter), and `llms.txt` links to them. In layouts, add `data-vage-copy="skip"` to repeated extras such as share links or a call to action to leave them out of the copy. On Cloudflare, each copy gets a canonical `Link` header pointing to its page. `seo: { lastmod: "git" }` dates the sitemap from git history (needs full history in CI: `fetch-depth: 0`).
|
|
@@ -144,7 +146,9 @@ Always ask the site owner before publishing or deploying. Show them the plan fir
|
|
|
144
146
|
|
|
145
147
|
After deploying, `npx vage indexnow --changed-since HEAD~1` (needs `integrations.indexNow.key`) tells IndexNow search engines about changed pages. It's a hint, not a guarantee of indexing; ask the owner first.
|
|
146
148
|
|
|
147
|
-
GitHub Pages needs a custom domain or a `<user>.github.io` repository, because vagejs does not support base paths yet.
|
|
149
|
+
GitHub Pages needs a custom domain or a `<user>.github.io` repository, because vagejs does not support base paths yet; `vage deploy` stops before pushing when the site would land in a subfolder.
|
|
150
|
+
|
|
151
|
+
Other static hosts (Vercel, Netlify and the like) work without a vagejs deploy command: build command `npm run build`, output folder `dist`, no framework preset. Config redirects become small forwarding pages there, and the `_headers` security headers apply only on Cloudflare.
|
|
148
152
|
`.github/workflows/ci.yml` runs check, build and audit on every push and PR, and uploads `dist/` as a downloadable preview artifact.
|
|
149
153
|
|
|
150
154
|
## Analytics and search reports
|
|
@@ -164,7 +168,7 @@ Off unless configured in `vage.config.ts` under `integrations`. Ask the site own
|
|
|
164
168
|
|
|
165
169
|
## Not implemented yet
|
|
166
170
|
|
|
167
|
-
Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths
|
|
171
|
+
Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths. Do not use or document them as available. A passing `check` does not guarantee indexing, rankings or AI citations.
|
|
168
172
|
|
|
169
173
|
## Favicon
|
|
170
174
|
|
|
@@ -10,8 +10,8 @@ export default defineConfig({
|
|
|
10
10
|
seo: {
|
|
11
11
|
robots: {
|
|
12
12
|
// AI crawler policy is your decision; unset means the "*" rules apply.
|
|
13
|
-
// aiTraining: "disallow", // GPTBot, ClaudeBot, Google-Extended
|
|
14
|
-
// aiSearch: "allow", // OAI-SearchBot, Claude-SearchBot
|
|
13
|
+
// aiTraining: "disallow", // GPTBot, ClaudeBot, Google-Extended, Applebot-Extended
|
|
14
|
+
// aiSearch: "allow", // OAI-SearchBot, Claude-SearchBot, PerplexityBot
|
|
15
15
|
},
|
|
16
16
|
sitemap: true,
|
|
17
17
|
llmsTxt: { full: false },
|