vagejs 0.3.5 → 0.5.0

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