vagejs 0.4.0 → 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
@@ -15,16 +15,16 @@
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
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)
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
19
 
20
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>
21
21
 
22
22
  - **Static by default**: plain HTML, no client JavaScript unless you add analytics.
23
23
  - **SEO built in**: `robots.txt`, `sitemap.xml`, `llms.txt`, canonical URLs, Open Graph and JSON-LD, generated from your pages.
24
24
  - **Checks before you ship**: `vage check` (links, SEO, structured data, accessibility) and `vage audit` (page-weight budgets, axe, optional Lighthouse and PageSpeed).
25
- - **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.
26
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.
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.
28
28
 
29
29
  ## Quick start
30
30
 
@@ -36,9 +36,17 @@ cd my-site
36
36
  npm install
37
37
  npm start
38
38
  ```
39
-
40
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.)
41
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
+
42
50
  ## Using vagejs
43
51
 
44
52
  ### Project layout
@@ -71,7 +79,7 @@ description: Plans and prices.
71
79
  Our plans start at …
72
80
  ```
73
81
 
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).
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).
75
83
 
76
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.
77
85
 
@@ -145,6 +153,7 @@ npx vage deploy:github --remote https://github.com/you/site.git # check, build
145
153
  npx vage deploy # GitHub Pages via Actions
146
154
  npx vage deploy --target cloudflare-workers --project my-site # Cloudflare Workers: new version, no downtime
147
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")
148
157
  ```
149
158
 
150
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.
@@ -201,6 +210,7 @@ vage deploy:github [--remote URL] [--branch B] [-m MSG] [--dry-run] [--yes] [--j
201
210
  vage deploy [--remote URL] [--no-wait] [--dry-run] [--yes] # GitHub Pages via Actions; confirms with GitHub
202
211
  vage deploy --target cloudflare-workers [--project NAME] # new Worker version at 100%; custom domains stay up
203
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
204
214
  vage indexnow --changed-since REF | --urls … | --all # IndexNow URL notification (asks first)
205
215
  vage connect gsc|ga4 / vage disconnect google # read-only Google OAuth (PKCE, loopback); token outside the repo
206
216
  vage connect pagespeed|crux / vage disconnect … # save your own Google API key for audit --url (outside the repo)
@@ -211,7 +221,7 @@ vage info [--json] # commands + config/frontmatte
211
221
 
212
222
  ## Planned, not implemented
213
223
 
214
- Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths, sitemap indexes.
224
+ Bing Webmaster Tools API, a Vercel adapter, CMS content sources, multi-language sites, base paths, sitemap indexes.
215
225
 
216
226
  ## Security
217
227
 
@@ -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`);
@@ -203,10 +205,10 @@ cli
203
205
  reportPublish(result);
204
206
  }));
205
207
  cli
206
- .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")
207
209
  .option("--root <dir>", "Project root", { default: "." })
208
- .option("--target <host>", "github-pages, cloudflare-workers or cloudflare-pages (default: deploy.target in config)")
209
- .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)")
210
212
  .option("--remote <url>", "Git URL to add as origin if there is none")
211
213
  .option("--branch <name>", "Branch for a new repository (default: main)")
212
214
  .option("-m, --message <msg>", "Commit message")
@@ -222,8 +224,13 @@ cli
222
224
  return deployToCloudflare(opts, config.deploy.cloudflareProject);
223
225
  if (target === "cloudflare-workers")
224
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
+ }
225
232
  if (target !== "github-pages")
226
- 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.`);
227
234
  const { publish } = await import("../deploy/publish.js");
228
235
  const pages = await import("../deploy/github-pages.js");
229
236
  // Stop before pushing a site GitHub Pages would serve from a subfolder (its links would break).
@@ -489,7 +496,9 @@ cli
489
496
  urls = opts.urls.split(",").map((u) => abs(u.trim()));
490
497
  else if (opts.changedSince) {
491
498
  const { changedPageRoutes } = await import("../deploy/git.js");
492
- 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);
493
502
  if (!urls.length)
494
503
  return opts.json ? print({ ok: true, submitted: 0 }) : console.log(`No page changes since ${opts.changedSince}.`);
495
504
  }
@@ -647,6 +656,40 @@ async function deployToWorkers(opts, configuredWorker, siteUrl) {
647
656
  if (deployment.status === "failure")
648
657
  process.exit(1);
649
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
+ }
650
693
  async function confirmPlan(plan, opts, extra) {
651
694
  const lines = [`Publish plan for ${plan.root}`];
652
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)",
@@ -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,10 +103,12 @@ 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
114
  ],
@@ -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 });
@@ -18,6 +18,9 @@ export declare function sanitizeHtml(html: string, options?: {
18
18
  publicDir?: string;
19
19
  inlineStyles?: boolean;
20
20
  variants?: Map<string, ImageVariant>;
21
+ headingIds?: boolean;
21
22
  }): Promise<string>;
22
- /** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
23
- export declare function enhanceTrustedHtml(html: string, publicDir: string, variants?: Map<string, ImageVariant>): Promise<string>;
23
+ /** For trusted content (`markdown.html: "allow"`): the same image hints and heading ids, applied through the DOM. */
24
+ export declare function enhanceTrustedHtml(html: string, publicDir: string, variants?: Map<string, ImageVariant>, headingIds?: boolean): Promise<string>;
25
+ /** "Quick start: 2 steps!" -> "quick-start-2-steps". Letters in any script are kept; accents are dropped. */
26
+ export declare function headingSlug(text: string): string;
@@ -29,9 +29,17 @@ export async function sanitizeHtml(html, options = {}) {
29
29
  first = false;
30
30
  }
31
31
  };
32
+ // A heading's own id is kept even when DOMPurify's clobbering filter would drop it ("title",
33
+ // "images"), so it behaves like the ids vagejs adds and links to it keep working.
34
+ const keepHeadingId = (node, data) => {
35
+ if (data.attrName === "id" && /^H[23]$/.test(node.nodeName))
36
+ data.forceKeepAttr = true;
37
+ };
32
38
  if (publicDir) {
33
39
  purifier.addHook("afterSanitizeAttributes", hook);
34
40
  }
41
+ if (options.headingIds)
42
+ purifier.addHook("uponSanitizeAttribute", keepHeadingId);
35
43
  const config = {
36
44
  // font/marquee/center: legacy ways to hide or disguise text (white-on-white, 1px scrollers).
37
45
  FORBID_TAGS: ["style", "form", "input", "button", "textarea", "select", "option", "base", "link", "meta", "object", "embed", "font", "marquee", "center"],
@@ -41,12 +49,16 @@ export async function sanitizeHtml(html, options = {}) {
41
49
  FORBID_ATTR: options.inlineStyles ? ["xlink:href"] : ["style", "xlink:href"],
42
50
  };
43
51
  try {
44
- if (![...(options.variants?.values() ?? [])].some((v) => v.avifSrcset))
52
+ const avif = [...(options.variants?.values() ?? [])].some((v) => v.avifSrcset);
53
+ if (!avif && !options.headingIds)
45
54
  return purifier.sanitize(html, config);
46
- // AVIF: wrap images in <picture> on DOMPurify's own output tree, then serialize once.
55
+ // Heading ids and AVIF <picture> wrappers are added on DOMPurify's own output tree, then serialized once.
47
56
  // (Editing sanitized HTML as a string and parsing it again could change its meaning.)
48
57
  const fragment = purifier.sanitize(html, { ...config, RETURN_DOM_FRAGMENT: true });
49
- addAvifSources(fragment, options.variants);
58
+ if (options.headingIds)
59
+ addHeadingIds(fragment);
60
+ if (avif)
61
+ addAvifSources(fragment, options.variants);
50
62
  const container = fragment.ownerDocument.createElement("div");
51
63
  container.appendChild(fragment);
52
64
  return container.innerHTML;
@@ -54,18 +66,51 @@ export async function sanitizeHtml(html, options = {}) {
54
66
  finally {
55
67
  if (publicDir)
56
68
  purifier.removeHook("afterSanitizeAttributes", hook);
69
+ if (options.headingIds)
70
+ purifier.removeHook("uponSanitizeAttribute", keepHeadingId);
57
71
  }
58
72
  }
59
- /** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
60
- export async function enhanceTrustedHtml(html, publicDir, variants) {
73
+ /** For trusted content (`markdown.html: "allow"`): the same image hints and heading ids, applied through the DOM. */
74
+ export async function enhanceTrustedHtml(html, publicDir, variants, headingIds = false) {
61
75
  const { jsdom } = await load();
62
76
  const template = new jsdom.JSDOM("").window.document.createElement("template");
63
77
  template.innerHTML = html;
64
78
  template.content.querySelectorAll("img").forEach((img, i) => enhanceImage(img, publicDir, i === 0, variants?.get(img.getAttribute("src") ?? "")));
79
+ if (headingIds)
80
+ addHeadingIds(template.content);
65
81
  if (variants)
66
82
  addAvifSources(template.content, variants);
67
83
  return template.innerHTML;
68
84
  }
85
+ /** "Quick start: 2 steps!" -> "quick-start-2-steps". Letters in any script are kept; accents are dropped. */
86
+ export function headingSlug(text) {
87
+ return text
88
+ .normalize("NFKD")
89
+ // Only Latin accents are dropped; marks are part of other scripts (Hindi vowel signs, kana voicing).
90
+ .replace(/[\u0300-\u036f]/g, "")
91
+ .normalize("NFC")
92
+ .toLowerCase()
93
+ .replace(/[^\p{L}\p{M}\p{N}]+/gu, "-")
94
+ .replace(/^-+|-+$/g, "");
95
+ }
96
+ /**
97
+ * Gives each h2 and h3 without an id one made from its text, so sections can be linked
98
+ * (/docs/setup/#quick-start). Ids already on the page are kept and never reused; repeats
99
+ * get -2, -3.
100
+ */
101
+ function addHeadingIds(root) {
102
+ const used = new Set([...root.querySelectorAll("[id]")].map((el) => el.id));
103
+ for (const heading of root.querySelectorAll("h2, h3")) {
104
+ if (heading.id)
105
+ continue;
106
+ const base = headingSlug(heading.textContent ?? "") || "section";
107
+ let id = base;
108
+ for (let n = 2; used.has(id); n++)
109
+ id = `${base}-${n}`;
110
+ used.add(id);
111
+ heading.id = id;
112
+ }
113
+ }
69
114
  /**
70
115
  * Offers AVIF first: <picture><source type="image/avif" srcset sizes><img …></picture>.
71
116
  * Browsers without AVIF use the <img> and its WebP srcset. Only images vagejs gave a
@@ -14,6 +14,11 @@ export interface BuildResult {
14
14
  }[];
15
15
  files: string[];
16
16
  skippedDrafts: string[];
17
+ /** Pages with a later datePublished, and the date they go live (on a build from that day). */
18
+ skippedScheduled: {
19
+ source: string;
20
+ date: string;
21
+ }[];
17
22
  warnings: string[];
18
23
  durationMs: number;
19
24
  }
@@ -54,6 +54,7 @@ export async function buildSite(root) {
54
54
  redirects: model.redirects.map((r) => ({ from: r.from, to: r.to, output: r.outFile })),
55
55
  files: [...model.files.keys()],
56
56
  skippedDrafts: model.skippedDrafts,
57
+ skippedScheduled: model.skippedScheduled.map(({ source, date }) => ({ source, date })),
57
58
  warnings: model.warnings,
58
59
  durationMs: Math.round(performance.now() - start),
59
60
  };
@@ -12,12 +12,17 @@ export declare const configSchema: z.ZodObject<{
12
12
  avif: z.ZodDefault<z.ZodBoolean>;
13
13
  sizes: z.ZodDefault<z.ZodString>;
14
14
  }, z.core.$strict>>;
15
+ tags: z.ZodDefault<z.ZodObject<{
16
+ pages: z.ZodDefault<z.ZodBoolean>;
17
+ layout: z.ZodOptional<z.ZodString>;
18
+ }, z.core.$strict>>;
15
19
  markdown: z.ZodDefault<z.ZodObject<{
16
20
  html: z.ZodDefault<z.ZodEnum<{
17
21
  allow: "allow";
18
22
  sanitize: "sanitize";
19
23
  }>>;
20
24
  inlineStyles: z.ZodDefault<z.ZodBoolean>;
25
+ headingIds: z.ZodDefault<z.ZodBoolean>;
21
26
  }, z.core.$strict>>;
22
27
  seo: z.ZodDefault<z.ZodObject<{
23
28
  robots: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
@@ -116,9 +121,11 @@ export declare const configSchema: z.ZodObject<{
116
121
  "cloudflare-pages": "cloudflare-pages";
117
122
  "cloudflare-workers": "cloudflare-workers";
118
123
  "github-pages": "github-pages";
124
+ netlify: "netlify";
119
125
  }>>;
120
126
  cloudflareProject: z.ZodOptional<z.ZodString>;
121
127
  cloudflareWorker: z.ZodOptional<z.ZodString>;
128
+ netlifySite: z.ZodOptional<z.ZodString>;
122
129
  }, z.core.$strict>>;
123
130
  security: z.ZodDefault<z.ZodObject<{
124
131
  headers: z.ZodDefault<z.ZodBoolean>;
@@ -194,6 +194,16 @@ export const configSchema = z.strictObject({
194
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.'),
195
195
  })
196
196
  .default({ optimize: false, widths: [480, 960, 1600], quality: 75, avif: false, sizes: "(max-width: 48rem) 100vw, 48rem" }),
197
+ tags: z
198
+ .strictObject({
199
+ pages: z.boolean().default(true).describe("Generate a page at /tags/<tag>/ for every tag used in page frontmatter, listing its pages, newest first."),
200
+ layout: z
201
+ .string()
202
+ .regex(/^[\w-]+$/, "layout must be a file name without extension")
203
+ .optional()
204
+ .describe("Layout for tag pages (src/layouts/<layout>.ts). Defaults to the default layout."),
205
+ })
206
+ .default({ pages: true }),
197
207
  markdown: z
198
208
  .strictObject({
199
209
  html: z
@@ -204,22 +214,31 @@ export const configSchema = z.strictObject({
204
214
  .boolean()
205
215
  .default(false)
206
216
  .describe('Keep style="..." attributes in page content (sanitize mode). Off by default: inline styles are the usual way to hide spam links. Put styling in layouts.'),
217
+ headingIds: z
218
+ .boolean()
219
+ .default(true)
220
+ .describe('Give each ## and ### heading in page content an id made from its text ("Quick start" -> id="quick-start"), so sections can be linked. Ids you write yourself are kept.'),
207
221
  })
208
- .default({ html: "sanitize", inlineStyles: false }),
222
+ .default({ html: "sanitize", inlineStyles: false, headingIds: true }),
209
223
  seo: seoSchema.default(() => seoSchema.parse({})),
210
224
  performance: performanceSchema.default(() => performanceSchema.parse({})),
211
225
  deploy: z
212
226
  .strictObject({
213
227
  target: z
214
- .enum(["github-pages", "cloudflare-pages", "cloudflare-workers"])
228
+ .enum(["github-pages", "cloudflare-pages", "cloudflare-workers", "netlify"])
215
229
  .default("github-pages")
216
- .describe("Host used by `vage deploy`. Cloudflare now creates new sites as Workers: prefer cloudflare-workers there."),
230
+ .describe("Host used by `vage deploy`. Cloudflare now creates new sites as Workers: prefer cloudflare-workers there. netlify also makes the build write Netlify's _redirects and _headers."),
217
231
  cloudflareProject: z.string().regex(/^[a-z0-9][a-z0-9-]{0,57}$/).optional().describe("Cloudflare Pages project name."),
218
232
  cloudflareWorker: z
219
233
  .string()
220
234
  .regex(/^[a-z0-9][a-z0-9-]{0,62}$/)
221
235
  .optional()
222
236
  .describe("Cloudflare Worker name (cloudflare-workers target, when the project has no wrangler config). Defaults to cloudflareProject."),
237
+ netlifySite: z
238
+ .string()
239
+ .regex(/^[a-z0-9][a-z0-9-]{0,62}$/)
240
+ .optional()
241
+ .describe("Netlify site name or ID (netlify target). Create the site in Netlify first."),
223
242
  })
224
243
  .default({ target: "github-pages" }),
225
244
  security: z
@@ -1,2 +1,8 @@
1
+ /**
2
+ * The publish date of a page that isn't due yet, else undefined. A date ("2026-11-01")
3
+ * is due from that day on the build machine's calendar (UTC on most CI); a datetime
4
+ * with an offset ("2026-11-01T09:00:00+05:30") from that moment.
5
+ */
6
+ export declare function scheduledFor(datePublished: string | undefined, now?: Date): string | undefined;
1
7
  /** A frontmatter date as a point in time ("2026-10-24" = midnight UTC); NaN when missing. */
2
8
  export declare function instant(date: string | undefined): number;
@@ -1,3 +1,17 @@
1
+ /**
2
+ * The publish date of a page that isn't due yet, else undefined. A date ("2026-11-01")
3
+ * is due from that day on the build machine's calendar (UTC on most CI); a datetime
4
+ * with an offset ("2026-11-01T09:00:00+05:30") from that moment.
5
+ */
6
+ export function scheduledFor(datePublished, now = new Date()) {
7
+ if (!datePublished)
8
+ return undefined;
9
+ if (datePublished.length === 10) {
10
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
11
+ return datePublished > today ? datePublished : undefined;
12
+ }
13
+ return Date.parse(datePublished) > now.getTime() ? datePublished : undefined;
14
+ }
1
15
  /** A frontmatter date as a point in time ("2026-10-24" = midnight UTC); NaN when missing. */
2
16
  export function instant(date) {
3
17
  return date ? Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date) : NaN;
@@ -15,7 +15,7 @@ export interface PageSummary {
15
15
  frontmatter: Frontmatter;
16
16
  }
17
17
  /**
18
- * Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
18
+ * Every published page (drafts, pages scheduled for a later date and 404.md excluded), sorted by route. Reads frontmatter
19
19
  * and applies each page's layout schema, so list entries get the same defaults as the
20
20
  * page itself. The result is frozen: layouts share it, so none may change it.
21
21
  * `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
@@ -35,6 +35,8 @@ export interface CollectionOptions {
35
35
  */
36
36
  sort?: "date" | "title" | "route" | "order" | ((a: PageSummary, b: PageSummary) => number);
37
37
  limit?: number;
38
+ /** Only pages with this tag (compared like tag page addresses: "SEO" matches "seo"). */
39
+ tag?: string;
38
40
  }
39
41
  /**
40
42
  * Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
@@ -43,6 +45,18 @@ export interface CollectionOptions {
43
45
  /** By `order` (lowest first; pages without one last), then title. */
44
46
  export declare function byOrder(a: PageSummary, b: PageSummary): number;
45
47
  export declare function collection(pages: readonly PageSummary[], options?: CollectionOptions): PageSummary[];
48
+ /** A tag's address part: "Web Design" -> "web-design" (tag pages live at /tags/<slug>/). */
49
+ export declare function tagSlug(tag: string): string;
50
+ export interface TagSummary {
51
+ /** The tag as first written in the pages, e.g. "SEO". */
52
+ name: string;
53
+ slug: string;
54
+ /** "/tags/<slug>/" (generated when tags.pages is on). */
55
+ route: string;
56
+ count: number;
57
+ }
58
+ /** Every tag the pages use, A to Z, with how many pages have it: for tag clouds and tag menus. */
59
+ export declare function tagList(pages: readonly PageSummary[]): TagSummary[];
46
60
  /** The pages before and after `route` in an ordered list (e.g. from collection()), for "Previous / Next" links. */
47
61
  export declare function neighbors(list: readonly PageSummary[], route: string): {
48
62
  prev?: PageSummary;
@@ -1,14 +1,15 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parseFrontmatter } from "../content/markdown.js";
4
+ import { headingSlug } from "../content/sanitize.js";
4
5
  import { pageMeta } from "../seo/head.js";
5
6
  import { clonePlain } from "./clone.js";
6
- import { instant } from "./dates.js";
7
+ import { instant, scheduledFor } from "./dates.js";
7
8
  import { applyLayoutFields } from "./render.js";
8
9
  import { VageError } from "./errors.js";
9
10
  import { discoverRoutes } from "./routes.js";
10
11
  /**
11
- * Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
12
+ * Every published page (drafts, pages scheduled for a later date and 404.md excluded), sorted by route. Reads frontmatter
12
13
  * and applies each page's layout schema, so list entries get the same defaults as the
13
14
  * page itself. The result is frozen: layouts share it, so none may change it.
14
15
  * `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
@@ -21,7 +22,7 @@ export async function collectPages(root, site, load, options = {}) {
21
22
  let frontmatter;
22
23
  try {
23
24
  ({ frontmatter } = parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file));
24
- if (frontmatter.draft)
25
+ if (frontmatter.draft || scheduledFor(frontmatter.datePublished))
25
26
  continue;
26
27
  await applyLayoutFields(root, frontmatter, load, entry.file);
27
28
  }
@@ -70,7 +71,9 @@ export function byOrder(a, b) {
70
71
  export function collection(pages, options = {}) {
71
72
  if (!Array.isArray(pages))
72
73
  throw new VageError("collection(): pass the layout's `pages` as the first argument.");
73
- const { under, includeIndex = false, sort = "route", limit } = options;
74
+ const { under, includeIndex = false, sort = "route", limit, tag } = options;
75
+ if (tag !== undefined && (typeof tag !== "string" || !tagSlug(tag)))
76
+ throw new VageError(`collection(): "tag" must be a tag name like "SEO" (got ${JSON.stringify(tag)}).`);
74
77
  if (under !== undefined && !/^\/(?!\/)/.test(under)) {
75
78
  throw new VageError(`collection(): "under" must be a site path starting with "/", like "/blog/" (got ${JSON.stringify(under)}).`);
76
79
  }
@@ -83,7 +86,7 @@ export function collection(pages, options = {}) {
83
86
  throw new VageError(`collection(): "limit" must be a whole number of 0 or more (got ${JSON.stringify(limit)}).`);
84
87
  }
85
88
  const prefix = under ? (under.endsWith("/") ? under : `${under}/`) : undefined;
86
- const picked = pages.filter((p) => !prefix || (p.route.startsWith(prefix) && (includeIndex || p.route !== prefix)));
89
+ const picked = pages.filter((p) => (!prefix || (p.route.startsWith(prefix) && (includeIndex || p.route !== prefix))) && (tag === undefined || (p.frontmatter.tags ?? []).some((t) => tagSlug(t) === tagSlug(tag))));
87
90
  const byTitle = (a, b) => a.title.localeCompare(b.title);
88
91
  const compare = typeof sort === "function"
89
92
  ? sort
@@ -103,6 +106,25 @@ export function collection(pages, options = {}) {
103
106
  const sorted = [...picked].sort(compare);
104
107
  return limit === undefined ? sorted : sorted.slice(0, limit);
105
108
  }
109
+ /** A tag's address part: "Web Design" -> "web-design" (tag pages live at /tags/<slug>/). */
110
+ export function tagSlug(tag) {
111
+ return headingSlug(tag);
112
+ }
113
+ /** Every tag the pages use, A to Z, with how many pages have it: for tag clouds and tag menus. */
114
+ export function tagList(pages) {
115
+ const tags = new Map();
116
+ for (const p of pages) {
117
+ for (const name of new Set(p.frontmatter.tags ?? [])) {
118
+ const slug = tagSlug(name);
119
+ if (!slug)
120
+ continue;
121
+ const t = tags.get(slug) ?? { name, slug, route: `/tags/${slug}/`, count: 0 };
122
+ t.count++;
123
+ tags.set(slug, t);
124
+ }
125
+ }
126
+ return [...tags.values()].sort((a, b) => a.name.localeCompare(b.name));
127
+ }
106
128
  /** The pages before and after `route` in an ordered list (e.g. from collection()), for "Previous / Next" links. */
107
129
  export function neighbors(list, route) {
108
130
  const i = list.findIndex((p) => p.route === route);
@@ -49,6 +49,8 @@ export declare function renderPage(options: {
49
49
  pages?: readonly PageSummary[];
50
50
  /** Resizer for images.optimize (tests inject one; sharp otherwise). */
51
51
  resize?: Resize;
52
+ /** The page's Markdown, for generated pages (tag pages); otherwise `file` is read. */
53
+ source?: string;
52
54
  }): Promise<RenderedPage>;
53
55
  /**
54
56
  * Loads the page's layout and, if it exports a `frontmatter` schema, validates the page
@@ -11,7 +11,7 @@ import { headTags, injectHead, pageMeta } from "../seo/head.js";
11
11
  import { analyticsTags, injectAnalytics } from "../integrations/tags.js";
12
12
  export async function renderPage(options) {
13
13
  const { root, file, route, site, load } = options;
14
- const parsed = parseMarkdown(fs.readFileSync(file, "utf8"), file);
14
+ const parsed = parseMarkdown(options.source ?? fs.readFileSync(file, "utf8"), file);
15
15
  const { frontmatter, body } = parsed;
16
16
  const publicDir = path.join(root, "public");
17
17
  const assets = new Map();
@@ -29,8 +29,8 @@ export async function renderPage(options) {
29
29
  }
30
30
  }
31
31
  const content = site.markdown.html === "allow"
32
- ? await enhanceTrustedHtml(parsed.html, publicDir, variants)
33
- : await sanitizeHtml(parsed.html, { publicDir, inlineStyles: site.markdown.inlineStyles, variants });
32
+ ? await enhanceTrustedHtml(parsed.html, publicDir, variants, site.markdown.headingIds)
33
+ : await sanitizeHtml(parsed.html, { publicDir, inlineStyles: site.markdown.inlineStyles, variants, headingIds: site.markdown.headingIds });
34
34
  const layout = await applyLayoutFields(root, frontmatter, load, file);
35
35
  const meta = pageMeta(site, route, frontmatter, options.notFound);
36
36
  const result = await layout({
@@ -1,5 +1,6 @@
1
1
  import { type ModuleLoader, type SiteConfig } from "./config.js";
2
2
  import { type RenderedPage } from "./render.js";
3
+ import { type PageSummary } from "./pages.js";
3
4
  export interface SitePage extends RenderedPage {
4
5
  route: string;
5
6
  /** Source path relative to the project root. */
@@ -28,6 +29,12 @@ export interface SiteModel {
28
29
  skippedDrafts: string[];
29
30
  /** Routes of draft pages; links to them are broken in production. */
30
31
  draftRoutes: string[];
32
+ /** Pages whose datePublished is still ahead: skipped until a build on or after that date. */
33
+ skippedScheduled: {
34
+ source: string;
35
+ route: string;
36
+ date: string;
37
+ }[];
31
38
  notFound?: SitePage;
32
39
  redirects: SiteRedirect[];
33
40
  /** Generated discovery files: output path -> content. */
@@ -38,5 +45,7 @@ export interface SiteModel {
38
45
  export declare function renderSite(root: string, load: ModuleLoader, options?: {
39
46
  collectErrors?: boolean;
40
47
  }): Promise<SiteModel>;
48
+ /** A tag page's Markdown: a heading and the tagged pages, newest first. Written as HTML so tag and page names can't become Markdown. */
49
+ export declare function tagPageSource(site: SiteConfig, name: string, tagged: PageSummary[]): string;
41
50
  /** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
42
51
  export declare function listedPages(model: SiteModel): SitePage[];
package/dist/core/site.js CHANGED
@@ -7,7 +7,7 @@ import { copySource, htmlToMarkdown, markdownCopy, markdownCopyPath } from "../d
7
7
  import { robotsTxt } from "../discovery/robots.js";
8
8
  import { SITEMAP_URL_LIMIT, sitemapXml } from "../discovery/sitemap.js";
9
9
  import { copyCanonicalsFit, headersFile } from "../deploy/headers.js";
10
- import { cloudflareRedirects } from "../deploy/redirects.js";
10
+ import { cloudflareRedirects, netlifyRedirects } from "../deploy/redirects.js";
11
11
  import { findSecrets } from "../deploy/secrets.js";
12
12
  import { rulePack } from "../guidelines/pack.js";
13
13
  import { absoluteUrl } from "../seo/urls.js";
@@ -17,7 +17,8 @@ import { escapeHtml } from "./html.js";
17
17
  import { renderPage } from "./render.js";
18
18
  import { parseFrontmatter } from "../content/markdown.js";
19
19
  import { sharpResize } from "../content/optimize.js";
20
- import { collectPages } from "./pages.js";
20
+ import { scheduledFor } from "./dates.js";
21
+ import { collection, collectPages, tagList } from "./pages.js";
21
22
  import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "./routes.js";
22
23
  /** Renders every page and derives discovery files from the same data, without writing anything. */
23
24
  export async function renderSite(root, load, options = {}) {
@@ -31,7 +32,7 @@ export async function renderSite(root, load, options = {}) {
31
32
  throw new VageError(`This project pins rule pack ${pinned}, but the installed vagejs ships ${rulePack.version}. ` +
32
33
  `Review what changed with "vage guidelines", then update guidelines.rulePack in vage.config.ts.`);
33
34
  }
34
- const model = { site, pageErrors: [], pages: [], skippedDrafts: [], draftRoutes: [], redirects: [], files: new Map(), warnings: [] };
35
+ const model = { site, pageErrors: [], pages: [], skippedDrafts: [], draftRoutes: [], skippedScheduled: [], redirects: [], files: new Map(), warnings: [] };
35
36
  const collect = options.collectErrors === true;
36
37
  const attempt = async (file, route, work) => {
37
38
  try {
@@ -51,16 +52,37 @@ export async function renderSite(root, load, options = {}) {
51
52
  for (const entry of discoverRoutes(pagesDir)) {
52
53
  const source = path.relative(root, entry.file);
53
54
  await attempt(entry.file, entry.route, async () => {
54
- // Drafts are skipped before rendering, so their fields can't fail a build.
55
- if (parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file).frontmatter.draft) {
55
+ // Drafts and scheduled pages are skipped before rendering, so their fields can't fail a build.
56
+ const { frontmatter } = parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file);
57
+ if (frontmatter.draft) {
56
58
  model.skippedDrafts.push(source);
57
59
  model.draftRoutes.push(entry.route);
58
60
  return;
59
61
  }
62
+ const date = scheduledFor(frontmatter.datePublished);
63
+ if (date) {
64
+ model.skippedScheduled.push({ source, route: entry.route, date });
65
+ return;
66
+ }
60
67
  const page = await renderPage({ root, file: entry.file, route: entry.route, site, load, analytics: true, pages, resize });
61
68
  model.pages.push({ ...page, route: entry.route, source, outFile: entry.outFile });
62
69
  });
63
70
  }
71
+ if (site.tags.pages) {
72
+ // Only published pages can clash: a draft or scheduled page at a tag address isn't in the build.
73
+ const taken = new Map(model.pages.map((p) => [p.route, p.source]));
74
+ for (const tag of tagList(pages)) {
75
+ const file = path.join(pagesDir, "tags", `${tag.slug}.md`);
76
+ const source = `(tag "${tag.name}")`;
77
+ await attempt(file, tag.route, async () => {
78
+ if (taken.has(tag.route)) {
79
+ throw new VageError(`${taken.get(tag.route)} and the tag "${tag.name}" both make ${tag.route}. Rename one, or set tags.pages: false in vage.config.ts.`);
80
+ }
81
+ const page = await renderPage({ root, file, route: tag.route, site, load, analytics: true, pages, resize, source: tagPageSource(site, tag.name, collection(pages, { tag: tag.name, sort: "date" })) });
82
+ model.pages.push({ ...page, route: tag.route, source, outFile: `${tag.route.slice(1)}index.html` });
83
+ });
84
+ }
85
+ }
64
86
  const notFound = findNotFoundPage(pagesDir);
65
87
  if (notFound) {
66
88
  await attempt(notFound, undefined, async () => {
@@ -79,6 +101,23 @@ export async function renderSite(root, load, options = {}) {
79
101
  addDiscoveryFiles(root, model, markdown);
80
102
  return model;
81
103
  }
104
+ /** A tag page's Markdown: a heading and the tagged pages, newest first. Written as HTML so tag and page names can't become Markdown. */
105
+ export function tagPageSource(site, name, tagged) {
106
+ const items = tagged.map((p) => `<li><a href="${escapeHtml(p.route)}">${escapeHtml(p.title)}</a>${p.description ? `: ${escapeHtml(p.description)}` : ""}</li>`);
107
+ const description = `Pages${site.title ? ` on ${site.title}` : ""} tagged ${name}.`;
108
+ return [
109
+ "---",
110
+ `title: ${JSON.stringify(`Tagged: ${name}`)}`,
111
+ `description: ${JSON.stringify(description)}`,
112
+ ...(site.tags.layout ? [`layout: ${site.tags.layout}`] : []),
113
+ "---",
114
+ "",
115
+ `<h1>Tagged: ${escapeHtml(name)}</h1>`,
116
+ "",
117
+ `<ul>\n${items.join("\n")}\n</ul>`,
118
+ "",
119
+ ].join("\n");
120
+ }
82
121
  /** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
83
122
  export function listedPages(model) {
84
123
  return model.pages.filter((p) => !p.meta.noindex && p.meta.canonical === absoluteUrl(model.site.site, p.route));
@@ -134,11 +173,14 @@ function addDiscoveryFiles(root, model, markdown) {
134
173
  const indexNow = site.integrations.indexNow;
135
174
  if (indexNow)
136
175
  generate(`${indexNow.key}.txt`, () => `${indexNow.key}\n`);
137
- // Cloudflare Pages and Workers static assets both read _redirects and _headers.
176
+ // Cloudflare Pages, Workers static assets and Netlify all read _redirects and _headers.
177
+ const netlify = site.deploy.target === "netlify";
138
178
  const cloudflare = site.deploy.target === "cloudflare-pages" || site.deploy.target === "cloudflare-workers";
139
179
  if (cloudflare && model.redirects.length)
140
180
  generate("_redirects", () => cloudflareRedirects(model.redirects));
141
- if (cloudflare && site.security.headers) {
181
+ if (netlify && model.redirects.length)
182
+ generate("_redirects", () => netlifyRedirects(model.redirects));
183
+ if ((cloudflare || netlify) && site.security.headers) {
142
184
  const html = [...model.pages, ...(model.notFound ? [model.notFound] : []), ...model.redirects].map((p) => p.html);
143
185
  // Each Markdown copy names its HTML page as canonical, so search engines don't index both.
144
186
  const copies = site.seo.markdownCopies && site.site ? listed.map((p) => ({ path: `/${markdownCopyPath(p.route)}`, canonical: p.meta.canonical })).filter((c) => /^\/[A-Za-z0-9._~\/-]+$/.test(c.path) && /^[^\s<>]+$/.test(c.canonical)) : [];
@@ -0,0 +1,22 @@
1
+ import type { DeployStatus, Runner } from "./github-pages.js";
2
+ /** Pinned so deploys are reproducible; checked 2026-10-25. */
3
+ export declare const NETLIFY_CLI = "netlify-cli@27.12.1";
4
+ export interface NetlifyDeployment extends DeployStatus {
5
+ site: string;
6
+ deployId?: string;
7
+ }
8
+ /**
9
+ * Uploads dist/ to a Netlify site's production deploy with netlify-cli (no Netlify build:
10
+ * vagejs already built it). Auth comes from NETLIFY_AUTH_TOKEN or a prior `netlify login`,
11
+ * never a command-line flag. The build wrote Netlify's _headers and _redirects (forced
12
+ * 301s, so they win over the HTML forwarding pages). Success needs netlify-cli to
13
+ * succeed AND the live site to answer 200: `siteUrl` when set, else the site's Netlify URL.
14
+ */
15
+ export declare function deployNetlify(root: string, options: {
16
+ site: string;
17
+ siteUrl?: string;
18
+ run?: Runner;
19
+ fetch?: typeof fetch;
20
+ sleep?: (ms: number) => Promise<void>;
21
+ env?: Record<string, string | undefined>;
22
+ }): Promise<NetlifyDeployment>;
@@ -0,0 +1,48 @@
1
+ import { redact } from "../integrations/http.js";
2
+ import { runCommand } from "./github-pages.js";
3
+ /** Pinned so deploys are reproducible; checked 2026-10-25. */
4
+ export const NETLIFY_CLI = "netlify-cli@27.12.1";
5
+ /**
6
+ * Uploads dist/ to a Netlify site's production deploy with netlify-cli (no Netlify build:
7
+ * vagejs already built it). Auth comes from NETLIFY_AUTH_TOKEN or a prior `netlify login`,
8
+ * never a command-line flag. The build wrote Netlify's _headers and _redirects (forced
9
+ * 301s, so they win over the HTML forwarding pages). Success needs netlify-cli to
10
+ * succeed AND the live site to answer 200: `siteUrl` when set, else the site's Netlify URL.
11
+ */
12
+ export async function deployNetlify(root, options) {
13
+ const run = options.run ?? runCommand;
14
+ const doFetch = options.fetch ?? fetch;
15
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
16
+ const secrets = [(options.env ?? process.env).NETLIFY_AUTH_TOKEN];
17
+ const { site } = options;
18
+ const res = await run("npx", ["--yes", NETLIFY_CLI, "deploy", "--dir", "dist", "--prod", "--no-build", "--site", site, "--json"], root);
19
+ const output = redact(`${res.stdout}\n${res.stderr}`, secrets);
20
+ if (res.code !== 0) {
21
+ const auth = /auth|log ?in|token|unauthori[sz]ed|401/i.test(output) ? " Set NETLIFY_AUTH_TOKEN, or run `npx netlify-cli login`." : "";
22
+ const missing = /not found|404|no (?:project|site)/i.test(output) ? ` Check that the Netlify site "${site}" exists (deploy.netlifySite is its name or ID).` : "";
23
+ return { status: "failure", site, reason: `netlify-cli failed (exit ${res.code}).${auth}${missing}\n${tail(output)}` };
24
+ }
25
+ let data = {};
26
+ try {
27
+ data = JSON.parse(res.stdout.slice(res.stdout.indexOf("{")));
28
+ }
29
+ catch {
30
+ // Older or newer netlify-cli output; the live check below still decides.
31
+ }
32
+ const deployId = typeof data.deploy_id === "string" ? data.deploy_id : undefined;
33
+ const netlifyUrl = typeof data.url === "string" && /^https:\/\//.test(data.url) ? data.url : undefined;
34
+ const pageUrl = options.siteUrl ?? netlifyUrl;
35
+ if (!pageUrl)
36
+ return { status: "unconfirmed", site, deployId, reason: `Uploaded, but there is no URL to check: set \`site\` in vage.config.ts.\n${tail(output)}` };
37
+ for (let i = 0; i < 5; i++) {
38
+ const page = await doFetch(pageUrl, { signal: AbortSignal.timeout(20_000) }).catch(() => undefined);
39
+ if (page?.ok)
40
+ return { status: "success", site, deployId, pageUrl };
41
+ await sleep(3000);
42
+ }
43
+ const own = netlifyUrl && netlifyUrl !== pageUrl ? ` The site itself is at ${netlifyUrl}. If ${pageUrl} is your own domain, add it in Netlify under Domain management.` : "";
44
+ return { status: "unconfirmed", site, deployId, pageUrl, reason: `Deployed, but ${pageUrl} did not respond with 200 yet. Check it in a minute.${own}` };
45
+ }
46
+ function tail(text) {
47
+ return text.trim().split("\n").slice(-8).join("\n");
48
+ }
@@ -5,3 +5,8 @@ import type { SiteRedirect } from "../core/site.js";
5
5
  * Both "/old" and "/old/" are listed so either form redirects.
6
6
  */
7
7
  export declare function cloudflareRedirects(redirects: SiteRedirect[]): string;
8
+ /**
9
+ * Netlify `_redirects`: the same rules, forced ("301!"). Netlify serves an existing file
10
+ * before a redirect, and vagejs writes an HTML forwarding page at each old address.
11
+ */
12
+ export declare function netlifyRedirects(redirects: SiteRedirect[]): string;
@@ -16,3 +16,11 @@ export function cloudflareRedirects(redirects) {
16
16
  throw new VageError(`_redirects would have ${lines.length} rules; Cloudflare Pages allows ${maxStaticRules}.`);
17
17
  return `# Generated by vagejs from redirects in vage.config.ts\n${lines.join("\n")}\n`;
18
18
  }
19
+ /**
20
+ * Netlify `_redirects`: the same rules, forced ("301!"). Netlify serves an existing file
21
+ * before a redirect, and vagejs writes an HTML forwarding page at each old address.
22
+ */
23
+ export function netlifyRedirects(redirects) {
24
+ const lines = redirects.flatMap((r) => (r.from === "/" ? ["/"] : [r.from, r.from.replace(/\/$/, "")]).map((from) => `${from} ${r.to} 301!`));
25
+ return `# Generated by vagejs from redirects in vage.config.ts\n${lines.join("\n")}\n`;
26
+ }
@@ -7,8 +7,8 @@ import { escapeHtml } from "../core/html.js";
7
7
  import { baseViteConfig, viteLoader } from "../core/loader.js";
8
8
  import { renderPage } from "../core/render.js";
9
9
  import { cacheDir } from "../content/optimize.js";
10
- import { collectPages } from "../core/pages.js";
11
- import { renderSite } from "../core/site.js";
10
+ import { collection, collectPages, tagList } from "../core/pages.js";
11
+ import { renderSite, tagPageSource } from "../core/site.js";
12
12
  import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "../core/routes.js";
13
13
  import { devAccessGuard } from "./guard.js";
14
14
  import { listenOrExplain } from "./listen.js";
@@ -91,6 +91,17 @@ export async function startDevServer(root, options = {}) {
91
91
  if (entry && !pathname.endsWith("/") && !pathname.endsWith(".html")) {
92
92
  return redirect(res, entry.route + urlPath.slice(pathname.length));
93
93
  }
94
+ if (!entry && site.tags.pages && route.startsWith("/tags/")) {
95
+ const pages = await collectPages(root, site, load, { skipInvalid: true });
96
+ const tag = tagList(pages).find((t) => t.route === route);
97
+ if (tag) {
98
+ if (!pathname.endsWith("/"))
99
+ return redirect(res, tag.route + urlPath.slice(pathname.length));
100
+ const source = tagPageSource(site, tag.name, collection(pages, { tag: tag.name, sort: "date" }));
101
+ const page = await renderPage({ root, file: path.join(pagesDir, "tags", `${tag.slug}.md`), route, site, load, pages, source });
102
+ return send(res, 200, await server.transformIndexHtml(urlPath, page.html));
103
+ }
104
+ }
94
105
  const notFound = findNotFoundPage(pagesDir);
95
106
  if (!entry && !notFound) {
96
107
  return send(res, 404, await server.transformIndexHtml(urlPath, plainPage("404 — Not found", `No page for ${route}`)));
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { escapeHtml, html, raw, type SafeHtml } from "./core/html.js";
4
4
  export { z } from "zod";
5
5
  export type { Layout, LayoutProps } from "./core/render.js";
6
6
  export type { Frontmatter } from "./content/markdown.js";
7
- export { breadcrumbs, collection, flatten, neighbors, tree, type CollectionOptions, type PageSummary, type TreeNode } from "./core/pages.js";
7
+ export { breadcrumbs, collection, flatten, neighbors, tagList, tagSlug, tree, type CollectionOptions, type PageSummary, type TagSummary, type TreeNode } from "./core/pages.js";
8
8
  export { build, type BuildResult } from "./core/build.js";
9
9
  export { startDevServer } from "./dev/server.js";
10
10
  export { startPreviewServer } from "./dev/preview.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ export { defineConfig } from "./core/config.js";
2
2
  export { escapeHtml, html, raw } from "./core/html.js";
3
3
  /** Zod, for layout field schemas: `export const frontmatter = z.object({ ... })`. Same instance vagejs validates with. */
4
4
  export { z } from "zod";
5
- export { breadcrumbs, collection, flatten, neighbors, tree } from "./core/pages.js";
5
+ export { breadcrumbs, collection, flatten, neighbors, tagList, tagSlug, tree } from "./core/pages.js";
6
6
  export { build } from "./core/build.js";
7
7
  export { startDevServer } from "./dev/server.js";
8
8
  export { startPreviewServer } from "./dev/preview.js";
@@ -1,4 +1,4 @@
1
- /** Published pages and drafts, without writing anything. */
1
+ /** Published pages, drafts and scheduled pages, without writing anything. */
2
2
  export declare function listPages(root: string): Promise<{
3
3
  pages: {
4
4
  route: string;
@@ -9,6 +9,10 @@ export declare function listPages(root: string): Promise<{
9
9
  noindex: boolean;
10
10
  }[];
11
11
  drafts: string[];
12
+ scheduled: {
13
+ source: string;
14
+ date: string;
15
+ }[];
12
16
  errors: import("../core/site.js").PageError[];
13
17
  }>;
14
18
  /**
@@ -24,7 +24,7 @@ async function loadSdk(root) {
24
24
  }
25
25
  throw new VageError(INSTALL);
26
26
  }
27
- /** Published pages and drafts, without writing anything. */
27
+ /** Published pages, drafts and scheduled pages, without writing anything. */
28
28
  export async function listPages(root) {
29
29
  const loader = await createBuildLoader(root);
30
30
  try {
@@ -39,6 +39,7 @@ export async function listPages(root) {
39
39
  noindex: Boolean(p.meta.noindex),
40
40
  })),
41
41
  drafts: model.skippedDrafts,
42
+ scheduled: model.skippedScheduled.map(({ source, date }) => ({ source, date })),
42
43
  errors: model.pageErrors,
43
44
  };
44
45
  }
@@ -74,7 +75,7 @@ export async function startMcpServer(root, options) {
74
75
  });
75
76
  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
77
  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_pages", { title: "List pages", description: "Every published page (route, source file, title, description, layout, noindex), the drafts and scheduled pages that are skipped, and pages that fail to render.", annotations: READ_ONLY }, tool(() => listPages(root)));
78
79
  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
80
  await server.connect(options.transport ?? new sdk.StdioServerTransport());
80
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vagejs",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -16,6 +16,15 @@ This website uses **vagejs** (pre-release). Only the features listed here exist
16
16
  Add `--json` to `npx vage build`, `check` or `audit` for machine-readable output (`{ ok: false, error }` on failure).
17
17
  Run `npm run check` after every change and fix all errors before finishing.
18
18
 
19
+ ## Windows
20
+
21
+ vagejs is tested on Windows on every change.
22
+
23
+ - Node.js **22.12 or newer** is needed. Check with `node --version` in a new PowerShell or Command Prompt window.
24
+ - The `npm` and `npx` commands in this file work in PowerShell and Command Prompt. In Command Prompt, leave out any `# …` notes after a command: it doesn't treat `#` as a comment.
25
+ - Windows usually saves files with **CRLF** line endings. vagejs handles them; don't convert line endings.
26
+ - If a browser shows **HTTP 431** on `localhost`, open the direct `[::1]` or `127.0.0.1` address that `npm start` and `npm run dev` print.
27
+
19
28
  ## Machine-readable reference
20
29
 
21
30
  `npx vage info --json` prints every command and option plus JSON Schemas for `vage.config.ts` and page frontmatter. Prefer it over guessing.
@@ -33,6 +42,9 @@ Run `npm run check` after every change and fix all errors before finishing.
33
42
  - **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.
34
43
  - **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).
35
44
  - **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`).
45
+ - **Link to a section:** every `##` and `###` heading gets an id from its text (`## Quick start` becomes `id="quick-start"`), so link to `/docs/setup/#quick-start`. `npm run check` warns when a `#section` matches no id on the target page (`link-fragment`). Write `<h2 id="...">` in Markdown for an id of your own. Ids are only kept unique within the page content, so avoid headings that repeat an id the layout uses (such as `main`); `markdown: { headingIds: false }` turns automatic ids off.
46
+ - **Schedule a post:** give it a future `datePublished` (and no `draft: true`). It stays out of builds, lists, feeds and the sitemap until a build on or after that day; `npm run build` lists it as `(scheduled for <date>, skipped)`. A date goes live on that day in the build machine's time zone (UTC on most CI); use a datetime such as `2026-11-01T09:00:00+05:30` for an exact time. Nothing rebuilds the site by itself: the owner needs a deploy on that day (for example a scheduled GitHub Actions workflow). `vage indexnow --changed-since` skips drafts and scheduled pages; once the page is live, submit it with `--urls`.
47
+ - **Tag pages:** `tags: [SEO, AI]` in frontmatter. Each tag gets a page at `/tags/<tag>/` listing its pages, newest first (`tags: { layout: "<name>" }` picks its layout, `tags: { pages: false }` turns them off). In layouts, `collection(pages, { tag: "SEO" })` picks tagged pages and `tagList(pages)` lists every tag with its `route` and `count`. Tags are matched by their address, so "SEO" and "seo" are the same tag.
36
48
  - **Rename a page:** move the file, then add `redirects: { "/old-slug/": "/new-slug/" }` to `vage.config.ts`.
37
49
  - **Hide a page from search:** `noindex: true` (it stays published). To unpublish, use `draft: true` or delete it.
38
50
  - **AI crawler policy:** set `seo.robots.aiTraining` / `aiSearch` only on the owner's instruction.
@@ -66,10 +78,11 @@ canonical: /other/ # canonical override (path or absolute URL); page then
66
78
  image: /images/og.png # og:image (made absolute using `site`)
67
79
  type: article # og:type + Article JSON-LD (default: website)
68
80
  author: Jane Doe # Article author
69
- datePublished: 2026-01-31
81
+ datePublished: 2026-01-31 # a future date schedules the page: it's skipped until a build on or after that day
70
82
  dateModified: 2026-02-15 # also the sitemap <lastmod> (otherwise datePublished, or git with seo.lastmod: "git")
71
83
  order: 2 # position in menus built with tree() or sort: "order" (lower first)
72
84
  navTitle: Pricing # shorter label for menus and breadcrumbs (PageSummary.label)
85
+ tags: [SEO, AI] # each tag gets a page at /tags/<tag>/
73
86
  ---
74
87
  ```
75
88
 
@@ -90,7 +103,7 @@ Hacked sites are usually abused for hidden spam links and doorway pages. vagejs
90
103
 
91
104
  HTML inside Markdown is sanitized: scripts, event handlers, `javascript:` links, iframes and forms are removed. Embeds (e.g. video) belong in a layout, which is trusted code. The owner can trust all authors with `markdown: { html: "allow" }`; don't set that yourself. Values inside `{ }` in frontmatter that contain a comma or colon must be quoted; `vage check` warns about empty values (`frontmatter-empty`). Never put secrets in `public/` (it is published, and the build refuses secret-looking files and symlinks there). `npm run dev -- --host` exposes the dev server to your network; it serves only pages and `public/`, but use it only on trusted networks.
92
105
 
93
- With `deploy.target: "cloudflare-workers"` or `"cloudflare-pages"`, the build writes `_headers` with security headers and a Content-Security-Policy computed from the pages (inline scripts are allowed by hash). Don't use inline event handlers (`onclick=`); add sources the site really needs to `security.cspSources`.
106
+ With `deploy.target: "cloudflare-workers"`, `"cloudflare-pages"` or `"netlify"`, the build writes `_headers` with security headers and a Content-Security-Policy computed from the pages (inline scripts are allowed by hash). Don't use inline event handlers (`onclick=`); add sources the site really needs to `security.cspSources`.
94
107
 
95
108
  ## Layouts
96
109
 
@@ -115,14 +128,14 @@ From the published pages and config (never from `public/`; a hand-written `publi
115
128
  - `sitemap.xml`: indexable, self-canonical, non-draft pages. Needs `site`.
116
129
  - `llms.txt` (+ `llms-full.txt` with `llmsTxt: { full: true }`): same pages. An emerging convention; not a guarantee that AI tools use it.
117
130
  - Redirects: `redirects: { "/old/": "/new/" }` become noindex meta-refresh pages (dev server answers 301).
118
- - With `seo.feeds`: `<section>/feed.xml` (RSS). With `seo.markdownCopies`: `/<path>.md` copies of listed pages. With `images.optimize`: resized images in `/_vage/img/`.
131
+ - Tag pages at `/tags/<tag>/` for every tag in frontmatter (`tags.pages`). With `seo.feeds`: `<section>/feed.xml` (RSS). With `seo.markdownCopies`: `/<path>.md` copies of listed pages. With `images.optimize`: resized images in `/_vage/img/`.
119
132
 
120
133
 
121
134
  ## Config
122
135
 
123
136
  `vage.config.ts` keys: `site` (origin only, no path), `title`, `description`, `lang`,
124
- `seo: { robots, sitemap, llmsTxt, structuredData, feeds, markdownCopies, lastmod }`, `redirects`, `markdown`,
125
- `images: { optimize, widths, quality, sizes }`, `performance`, `deploy: { target, cloudflareWorker, cloudflareProject }`,
137
+ `seo: { robots, sitemap, llmsTxt, structuredData, feeds, markdownCopies, lastmod }`, `redirects`, `markdown: { html, inlineStyles, headingIds }`, `tags: { pages, layout }`,
138
+ `images: { optimize, widths, quality, sizes }`, `performance`, `deploy: { target, cloudflareWorker, cloudflareProject, netlifySite }`,
126
139
  `security: { headers, csp, cspSources, outboundLinks, spamPatterns }`, `integrations`, `guidelines`.
127
140
  Keys not in this list are an error; `npx vage info --json` has the full schema.
128
141
 
@@ -142,13 +155,14 @@ Always ask the site owner before publishing or deploying. Show them the plan fir
142
155
  - `npx vage deploy` with `deploy: { target: "cloudflare-workers", cloudflareWorker: "<name>" }`: Cloudflare's current way to host static sites. Uploads a new version and switches all traffic to it, so custom domains stay up. For custom domains or routes, the owner adds a `wrangler.jsonc` (vagejs then uses it). Config redirects become real 301s (`_redirects`).
143
156
  - `npx vage deploy --target cloudflare-pages --project <name>`: older Cloudflare Pages projects. Non-production branches get preview URLs. Cloudflare now turns new Pages projects into Workers; use `cloudflare-workers` for new sites.
144
157
  - Both Cloudflare targets report success only when Wrangler succeeds and the site responds. They need `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` or `npx wrangler login`.
158
+ - `npx vage deploy` with `deploy: { target: "netlify", netlifySite: "<site name or ID>" }`: uploads `dist/` to the Netlify site's production deploy (create the site in Netlify first). The build writes Netlify's `_headers` (security headers) and `_redirects` (config redirects as forced 301s). It reports success only when netlify-cli succeeds and the site responds. Needs `NETLIFY_AUTH_TOKEN` or `npx netlify-cli login`.
145
159
  - `npx vage deploy`: GitHub Pages (default target). Adds `.github/workflows/deploy.yml` (if missing), publishes, enables Pages via `gh` if needed, then waits for the Actions run. It says "deployed" only when GitHub reports success.
146
160
 
147
161
  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.
148
162
 
149
163
  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
164
 
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.
165
+ Other static hosts (Vercel 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 and Netlify.
152
166
  `.github/workflows/ci.yml` runs check, build and audit on every push and PR, and uploads `dist/` as a downloadable preview artifact.
153
167
 
154
168
  ## Analytics and search reports
@@ -168,7 +182,7 @@ Off unless configured in `vage.config.ts` under `integrations`. Ask the site own
168
182
 
169
183
  ## Not implemented yet
170
184
 
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.
185
+ Bing Webmaster Tools API, a Vercel adapter, 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.
172
186
 
173
187
  ## Favicon
174
188
 
@@ -12,6 +12,6 @@
12
12
  "preview": "vage preview"
13
13
  },
14
14
  "devDependencies": {
15
- "vagejs": "^0.4.0"
15
+ "vagejs": "^0.5.0"
16
16
  }
17
17
  }