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 CHANGED
@@ -13,13 +13,18 @@
13
13
 
14
14
  # vagejs
15
15
 
16
- An AI-native, static-first web framework. Write pages in Markdown, and vagejs builds a fast static site with SEO, `llms.txt`, accessibility and security handled for you. It's built so AI coding agents can use it without guessing. **Pre-release (0.x).**
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 `![Alt text](/images/photo.jpg)` 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 `![Alt text](/images/photo.jpg)` 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, MCP server, AVIF images.
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
@@ -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.getAttribute("src"), "image", route);
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;
@@ -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. Needs the `sharp` package in the
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. */
@@ -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
- return (input, width, quality) => sharp(input).rotate().resize({ width, withoutEnlargement: true }).webp({ quality }).toBuffer();
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 entries = [];
65
- for (const w of widths) {
66
- const name = `${base}-${hash}-${w}.webp`;
67
- const cached = path.join(dir, name);
68
- if (!fs.existsSync(cached)) {
69
- const tmp = `${cached}.${process.pid}.tmp`;
70
- let data;
71
- try {
72
- data = await resize(input, w, options.quality);
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
- catch (err) {
75
- throw new VageError(`Couldn't optimize ${src}: ${err.message}. Replace the file, or turn off images.optimize.`);
76
- }
77
- fs.writeFileSync(tmp, data);
78
- fs.renameSync(tmp, cached);
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
- // Label with the file's real width (rotation can change it); publish each width once.
81
- const actual = imageSize(cached)?.width ?? w;
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 entries.length ? { srcset: entries.join(", "), files } : undefined;
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>;
@@ -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
- return purifier.sanitize(html, {
37
- // font/marquee/center: legacy ways to hide or disguise text (white-on-white, 1px scrollers).
38
- FORBID_TAGS: ["style", "form", "input", "button", "textarea", "select", "option", "base", "link", "meta", "object", "embed", "font", "marquee", "center"],
39
- ALLOW_DATA_ATTR: true,
40
- // Inline styles are the usual way to hide injected links (display:none, off-screen); layouts style pages.
41
- // xlink:href: SVG links, which would slip past link checks.
42
- FORBID_ATTR: options.inlineStyles ? ["xlink:href"] : ["style", "xlink:href"],
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
+ }
@@ -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<{
@@ -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
@@ -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
- if (!/\b10007\b|worker[^\n]*(?:does not exist|not found)/i.test(output))
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
- return { status: "unconfirmed", worker, mode, versionId, pageUrl, reason: `Deployed, but ${pageUrl} did not respond with 200 yet. Check it in a minute.` };
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.
@@ -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", "image/webp");
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.4",
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:** create `src/pages/<slug>.md` with `title` and `description` frontmatter; link to it as `/<slug>/` (trailing slash).
26
- - **Add a blog post:** `src/pages/blog/<slug>.md`; it appears on `/blog/` automatically (`src/layouts/blog.ts`). For Article data add `type: article`, plus `author` / `datePublished` only if the owner provided them.
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 `![Describe the image](/images/name.jpg)`. 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`). Only images in page content are optimized, not images a layout writes.
32
+ - **Add an image:** put it in `public/images/` and write `![Describe the image](/images/name.jpg)`. 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, AVIF images. Do not use or document them as available. A passing `check` does not guarantee indexing, rankings or AI citations.
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
 
@@ -12,6 +12,6 @@
12
12
  "preview": "vage preview"
13
13
  },
14
14
  "devDependencies": {
15
- "vagejs": "^0.3.4"
15
+ "vagejs": "^0.4.0"
16
16
  }
17
17
  }
@@ -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 },