vagejs 0.1.0 → 0.3.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.
Files changed (80) hide show
  1. package/README.md +196 -25
  2. package/dist/checks/check.d.ts +2 -0
  3. package/dist/checks/check.js +106 -2
  4. package/dist/checks/crawlability.d.ts +34 -0
  5. package/dist/checks/crawlability.js +142 -0
  6. package/dist/checks/integrity.d.ts +36 -0
  7. package/dist/checks/integrity.js +145 -0
  8. package/dist/checks/links.d.ts +18 -0
  9. package/dist/checks/links.js +47 -0
  10. package/dist/checks/verify.d.ts +42 -0
  11. package/dist/checks/verify.js +264 -0
  12. package/dist/cli/index.js +118 -10
  13. package/dist/cli/info.d.ts +46 -0
  14. package/dist/cli/info.js +65 -5
  15. package/dist/content/images.d.ts +26 -0
  16. package/dist/content/images.js +129 -0
  17. package/dist/content/markdown.d.ts +4 -0
  18. package/dist/content/markdown.js +8 -1
  19. package/dist/content/optimize.d.ts +31 -0
  20. package/dist/content/optimize.js +92 -0
  21. package/dist/content/sanitize.d.ts +18 -2
  22. package/dist/content/sanitize.js +49 -9
  23. package/dist/core/build.js +11 -0
  24. package/dist/core/clone.d.ts +6 -0
  25. package/dist/core/clone.js +16 -0
  26. package/dist/core/config.d.ts +26 -0
  27. package/dist/core/config.js +43 -4
  28. package/dist/core/dates.d.ts +2 -0
  29. package/dist/core/dates.js +4 -0
  30. package/dist/core/errors.d.ts +2 -0
  31. package/dist/core/errors.js +3 -0
  32. package/dist/core/html.d.ts +22 -0
  33. package/dist/core/html.js +31 -0
  34. package/dist/core/pages.d.ts +67 -0
  35. package/dist/core/pages.js +168 -0
  36. package/dist/core/render.d.ts +24 -1
  37. package/dist/core/render.js +69 -10
  38. package/dist/core/site.d.ts +12 -1
  39. package/dist/core/site.js +77 -18
  40. package/dist/core/validate.d.ts +2 -0
  41. package/dist/core/validate.js +6 -0
  42. package/dist/deploy/cloudflare-pages.js +3 -1
  43. package/dist/deploy/cloudflare-workers.d.ts +59 -0
  44. package/dist/deploy/cloudflare-workers.js +223 -0
  45. package/dist/deploy/github-pages.js +2 -0
  46. package/dist/deploy/headers.js +1 -1
  47. package/dist/deploy/publish.d.ts +1 -1
  48. package/dist/deploy/publish.js +3 -3
  49. package/dist/deploy/redirects.js +1 -1
  50. package/dist/deploy/secrets.js +1 -1
  51. package/dist/dev/guard.d.ts +2 -0
  52. package/dist/dev/guard.js +30 -1
  53. package/dist/dev/server.js +22 -4
  54. package/dist/discovery/feed.d.ts +21 -0
  55. package/dist/discovery/feed.js +68 -0
  56. package/dist/discovery/git-dates.d.ts +10 -0
  57. package/dist/discovery/git-dates.js +57 -0
  58. package/dist/discovery/markdown-copy.d.ts +13 -0
  59. package/dist/discovery/markdown-copy.js +112 -0
  60. package/dist/guidelines/rule-pack.json +101 -2
  61. package/dist/index.d.ts +4 -1
  62. package/dist/index.js +4 -1
  63. package/dist/integrations/api-keys.d.ts +1 -1
  64. package/dist/integrations/api-keys.js +1 -1
  65. package/dist/integrations/google-auth.js +1 -1
  66. package/dist/integrations/tags.d.ts +1 -1
  67. package/dist/integrations/tags.js +1 -1
  68. package/dist/seo/head.js +17 -1
  69. package/package.json +4 -1
  70. package/templates/starter/.github/dependabot.yml +11 -0
  71. package/templates/starter/.github/workflows/ci.yml +4 -0
  72. package/templates/starter/.github/workflows/verify.yml +34 -0
  73. package/templates/starter/AGENTS.md +45 -16
  74. package/templates/starter/links.lock.json +4 -0
  75. package/templates/starter/package.json +1 -1
  76. package/templates/starter/src/layouts/blog.ts +13 -0
  77. package/templates/starter/src/layouts/default.ts +8 -7
  78. package/templates/starter/src/pages/blog/index.md +7 -0
  79. package/templates/starter/src/pages/index.md +2 -2
  80. package/templates/starter/vage.config.ts +5 -1
@@ -0,0 +1,223 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { VageError } from "../core/errors.js";
5
+ import { redact } from "../integrations/http.js";
6
+ import { WRANGLER } from "./cloudflare-pages.js";
7
+ import { runCommand } from "./github-pages.js";
8
+ /** In wrangler's own lookup order. */
9
+ export const WRANGLER_CONFIGS = ["wrangler.json", "wrangler.jsonc", "wrangler.toml"];
10
+ /** Compatibility date for the generated config; Workers static assets need nothing newer. Checked 2026-10-23. */
11
+ export const COMPATIBILITY_DATE = "2026-10-01";
12
+ /**
13
+ * Reads name and assets settings from the project's wrangler config. Refuses setups where
14
+ * wrangler could pick a different file or Worker than the one shown: several config files,
15
+ * a CLOUDFLARE_ENV environment, or a .wrangler/deploy redirect.
16
+ */
17
+ export function readWranglerConfig(root, env = process.env) {
18
+ const present = WRANGLER_CONFIGS.filter((f) => fs.existsSync(path.join(root, f)));
19
+ if (present.length > 1)
20
+ throw new VageError(`Found ${present.join(" and ")}. Keep one wrangler config so it's clear which Worker gets deployed.`);
21
+ if (fs.existsSync(path.join(root, ".wrangler", "deploy", "config.json"))) {
22
+ throw new VageError(".wrangler/deploy/config.json redirects wrangler to another config. Remove it, or deploy with wrangler directly.");
23
+ }
24
+ const file = present[0];
25
+ if (!file)
26
+ return undefined;
27
+ if (env.CLOUDFLARE_ENV)
28
+ throw new VageError(`CLOUDFLARE_ENV="${env.CLOUDFLARE_ENV}" makes wrangler deploy that environment's Worker. Unset it to deploy with vagejs.`);
29
+ const text = fs.readFileSync(path.join(root, file), "utf8").replace(/^\uFEFF/, "");
30
+ let name, dir, nf;
31
+ if (file.endsWith(".toml")) {
32
+ const lines = text.split(/\r?\n/).map((l) => l.replace(/\s+#.*$|^\s*#.*$/, ""));
33
+ const header = (l) => /^\s*\[/.test(l);
34
+ const firstHeader = lines.findIndex(header);
35
+ const top = firstHeader === -1 ? lines : lines.slice(0, firstHeader);
36
+ const assetsStart = lines.findIndex((l) => /^\s*\[assets\]\s*$/.test(l));
37
+ const assetsEnd = assetsStart === -1 ? -1 : lines.findIndex((l, i) => i > assetsStart && header(l));
38
+ const assets = assetsStart === -1 ? [] : lines.slice(assetsStart + 1, assetsEnd === -1 ? undefined : assetsEnd);
39
+ const value = (block, key) => {
40
+ for (const l of block) {
41
+ const m = new RegExp(`^\\s*${key}\\s*=\\s*(?:"([^"]*)"|'([^']*)')\\s*$`).exec(l);
42
+ if (m)
43
+ return m[1] ?? m[2];
44
+ }
45
+ return undefined;
46
+ };
47
+ name = value(top, "name");
48
+ dir = value(assets, "directory");
49
+ nf = value(assets, "not_found_handling");
50
+ const inline = top.map((l) => /^\s*assets\s*=\s*\{(.*)\}\s*$/.exec(l)?.[1]).find(Boolean);
51
+ if (inline) {
52
+ dir ??= /directory\s*=\s*"([^"]*)"/.exec(inline)?.[1];
53
+ nf ??= /not_found_handling\s*=\s*"([^"]*)"/.exec(inline)?.[1];
54
+ }
55
+ }
56
+ else {
57
+ let data;
58
+ try {
59
+ data = JSON.parse(stripJsonComments(text));
60
+ }
61
+ catch (err) {
62
+ throw new VageError(`Could not read ${file}: ${err.message}`, path.join(root, file));
63
+ }
64
+ ({ name } = data);
65
+ dir = data.assets?.directory;
66
+ nf = data.assets?.not_found_handling;
67
+ }
68
+ return {
69
+ file,
70
+ name: typeof name === "string" ? name : undefined,
71
+ assetsDir: typeof dir === "string" ? path.resolve(root, dir) : undefined,
72
+ notFoundHandling: typeof nf === "string" ? nf : undefined,
73
+ };
74
+ }
75
+ /** JSONC to JSON: drops // and /* *\/ comments and trailing commas outside strings. */
76
+ function stripJsonComments(text) {
77
+ let out = "";
78
+ for (let i = 0; i < text.length; i++) {
79
+ const c = text[i];
80
+ if (c === '"') {
81
+ const end = /"(?:[^"\\]|\\.)*"/y;
82
+ end.lastIndex = i;
83
+ const m = end.exec(text);
84
+ const str = m ? m[0] : text.slice(i);
85
+ out += str;
86
+ i += str.length - 1;
87
+ }
88
+ else if (c === "/" && text[i + 1] === "/") {
89
+ while (i < text.length && text[i] !== "\n")
90
+ i++;
91
+ out += "\n";
92
+ }
93
+ else if (c === "/" && text[i + 1] === "*") {
94
+ i = text.indexOf("*/", i + 2);
95
+ if (i === -1)
96
+ break;
97
+ i++;
98
+ }
99
+ else if (c === "," && /^\s*(\/\/[^\n]*\n\s*|\/\*[\s\S]*?\*\/\s*)*[}\]]/.test(text.slice(i + 1))) {
100
+ // trailing comma: drop it
101
+ }
102
+ else
103
+ out += c;
104
+ }
105
+ return out;
106
+ }
107
+ /**
108
+ * Decides which Worker gets deployed, and refuses setups where the plan and what
109
+ * wrangler would actually do differ (another Worker name, or a folder other than dist/).
110
+ */
111
+ export function planWorkersDeploy(root, requested) {
112
+ const config = readWranglerConfig(root);
113
+ const warnings = [];
114
+ if (!config) {
115
+ if (!requested)
116
+ throw new VageError("No Worker name. Pass --project <name>, set deploy.cloudflareWorker, or add a wrangler.jsonc.");
117
+ return { worker: requested, warnings };
118
+ }
119
+ if (!config.name)
120
+ throw new VageError(`${config.file} has no "name". Add the Worker's name there.`, path.join(root, config.file));
121
+ if (requested && requested !== config.name) {
122
+ throw new VageError(`${config.file} deploys the Worker "${config.name}", but this deploy asks for "${requested}". ` +
123
+ `Make them match (deploy.cloudflareWorker / --project, or "name" in ${config.file}); vagejs won't deploy a different Worker than it shows.`);
124
+ }
125
+ const dist = path.join(root, "dist");
126
+ if (config.assetsDir !== dist) {
127
+ throw new VageError(`${config.file} must serve the built site: set assets.directory to "dist" (it is ${config.assetsDir ? `"${path.relative(root, config.assetsDir) || "."}"` : "not set"}).`, path.join(root, config.file));
128
+ }
129
+ if (config.notFoundHandling !== "404-page" && fs.existsSync(path.join(root, "src", "pages", "404.md"))) {
130
+ warnings.push(`${config.file}: add "not_found_handling": "404-page" under "assets", or unknown URLs get an empty 404 instead of your 404 page.`);
131
+ }
132
+ return { worker: config.name, config, warnings };
133
+ }
134
+ /**
135
+ * Deploys dist/ as a Cloudflare Worker with static assets (what Cloudflare now creates
136
+ * instead of Pages projects; `_headers` and `_redirects` still apply).
137
+ *
138
+ * - With a wrangler config in the project (needed for custom domains and routes), that
139
+ * file is used as-is; `planWorkersDeploy` has already checked its name and folder.
140
+ * Otherwise a temporary config pointing at dist/ is generated, so the project gains no files.
141
+ * - Existing Workers get `wrangler versions upload` + `versions deploy <id>@100%`: the new
142
+ * version takes all traffic without re-applying routes or custom domains, so the site
143
+ * stays up. Only Cloudflare's "Worker doesn't exist" error leads to a first `wrangler deploy`.
144
+ * - Success needs wrangler to succeed AND the live site to answer 200: `siteUrl` when set,
145
+ * else the Worker's workers.dev URL (never a version preview URL).
146
+ */
147
+ export async function deployCloudflareWorkers(root, options) {
148
+ const run = options.run ?? runCommand;
149
+ const doFetch = options.fetch ?? fetch;
150
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
151
+ const secrets = [(options.env ?? process.env).CLOUDFLARE_API_TOKEN];
152
+ const { worker } = options.plan;
153
+ let tempDir;
154
+ // Always name the config file, so wrangler can't pick another one (e.g. in a parent folder).
155
+ const configArgs = options.plan.config ? ["--config", path.join(root, options.plan.config.file)] : [];
156
+ if (!options.plan.config) {
157
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "vage-wrangler-"));
158
+ const config = {
159
+ name: worker,
160
+ compatibility_date: COMPATIBILITY_DATE,
161
+ assets: { directory: path.join(root, "dist"), not_found_handling: "404-page" },
162
+ };
163
+ fs.writeFileSync(path.join(tempDir, "wrangler.json"), JSON.stringify(config, null, 2));
164
+ configArgs.push("--config", path.join(tempDir, "wrangler.json"));
165
+ }
166
+ try {
167
+ const wrangler = (...args) => run("npx", ["--yes", WRANGLER, ...args, ...configArgs], root);
168
+ const out = (r) => redact(`${r.stdout}\n${r.stderr}`, secrets);
169
+ const failReason = (code, text) => {
170
+ const auth = /auth|login|token|unauthori[sz]ed|10000/i.test(text) ? " Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, or run `npx wrangler login`." : "";
171
+ return `wrangler failed (exit ${code}).${auth}\n${tail(text)}`;
172
+ };
173
+ let mode = "version";
174
+ let versionId;
175
+ let output;
176
+ const tag = options.tag ?? `vage-${Date.now()}`;
177
+ const upload = await wrangler("versions", "upload", "--tag", tag);
178
+ output = out(upload);
179
+ if (upload.code !== 0) {
180
+ // Only a Worker that doesn't exist yet gets a full deploy (which also attaches routes).
181
+ if (!/\b10007\b|worker[^\n]*(?:does not exist|not found)/i.test(output))
182
+ return { status: "failure", worker, reason: failReason(upload.code, output) };
183
+ mode = "first-deploy";
184
+ const first = await wrangler("deploy");
185
+ output = out(first);
186
+ if (first.code !== 0)
187
+ return { status: "failure", worker, mode, reason: failReason(first.code, output) };
188
+ }
189
+ else {
190
+ versionId = /Version ID:\s*([0-9a-f-]{36})/i.exec(output)?.[1];
191
+ if (!versionId)
192
+ return { status: "failure", worker, reason: `wrangler uploaded but printed no version ID.\n${tail(output)}` };
193
+ const switched = await wrangler("versions", "deploy", `${versionId}@100%`, "--yes");
194
+ if (switched.code !== 0)
195
+ return { status: "failure", worker, versionId, reason: `Version ${versionId} uploaded but not deployed. ${failReason(switched.code, out(switched))}` };
196
+ output += `\n${out(switched)}`;
197
+ }
198
+ // Lines mentioning "preview" carry version preview URLs, which show the new version even when the live site doesn't.
199
+ const workersDev = output
200
+ .split("\n")
201
+ .filter((line) => !/preview/i.test(line))
202
+ .join("\n")
203
+ .match(/https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev/gi)
204
+ ?.at(-1);
205
+ const pageUrl = options.siteUrl ?? workersDev;
206
+ if (!pageUrl)
207
+ return { status: "unconfirmed", worker, mode, versionId, reason: "Deployed, but there is no URL to check: set `site` in vage.config.ts." };
208
+ for (let i = 0; i < 5; i++) {
209
+ const page = await doFetch(pageUrl, { signal: AbortSignal.timeout(20_000) }).catch(() => undefined);
210
+ if (page?.ok)
211
+ return { status: "success", worker, mode, versionId, pageUrl };
212
+ await sleep(3000);
213
+ }
214
+ return { status: "unconfirmed", worker, mode, versionId, pageUrl, reason: `Deployed, but ${pageUrl} did not respond with 200 yet. Check it in a minute.` };
215
+ }
216
+ finally {
217
+ if (tempDir)
218
+ fs.rmSync(tempDir, { recursive: true, force: true });
219
+ }
220
+ }
221
+ function tail(text) {
222
+ return text.trim().split("\n").slice(-8).join("\n");
223
+ }
@@ -25,6 +25,8 @@ jobs:
25
25
  contents: read
26
26
  steps:
27
27
  - uses: actions/checkout@v7
28
+ with:
29
+ fetch-depth: 0 # full history, so seo.lastmod: "git" can date pages
28
30
  - uses: actions/setup-node@v7
29
31
  with:
30
32
  node-version: 22
@@ -89,7 +89,7 @@ export function contentSecurityPolicy(pagesHtml, site) {
89
89
  }
90
90
  /** Cloudflare Pages / Netlify `_headers` for every path. */
91
91
  export function headersFile(pagesHtml, site) {
92
- const lines = ["# Generated by VAGE JS (security in vage.config.ts)", "/*", " X-Content-Type-Options: nosniff", " Referrer-Policy: strict-origin-when-cross-origin", " X-Frame-Options: SAMEORIGIN", " Permissions-Policy: camera=(), microphone=(), geolocation=()"];
92
+ const lines = ["# Generated by vagejs (security in vage.config.ts)", "/*", " X-Content-Type-Options: nosniff", " Referrer-Policy: strict-origin-when-cross-origin", " X-Frame-Options: SAMEORIGIN", " Permissions-Policy: camera=(), microphone=(), geolocation=()"];
93
93
  if (site.security.csp !== "off") {
94
94
  const name = site.security.csp === "report-only" ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";
95
95
  lines.push(` ${name}: ${contentSecurityPolicy(pagesHtml, site)}`);
@@ -9,7 +9,7 @@ export interface PublishPlan {
9
9
  /** True when `--remote` will be added as "origin". */
10
10
  addRemote: boolean;
11
11
  files: ChangedFile[];
12
- /** Files VAGE JS will create (they do not exist yet). */
12
+ /** Files vagejs will create (they do not exist yet). */
13
13
  extraFiles: Record<string, string>;
14
14
  message: string;
15
15
  /** Local commits not yet on the remote (before this commit). */
@@ -51,7 +51,7 @@ export async function publish(root, options) {
51
51
  const out = push.stderr + push.stdout;
52
52
  if (/rejected|non-fast-forward|fetch first/i.test(out)) {
53
53
  throw new VageError(`Push rejected: origin/${plan.branch} has commits you don't have. Your commit is saved locally. ` +
54
- `Run "git pull --rebase origin ${plan.branch}", resolve any conflicts, then publish again. VAGE JS never force-pushes.`);
54
+ `Run "git pull --rebase origin ${plan.branch}", resolve any conflicts, then publish again. vagejs never force-pushes.`);
55
55
  }
56
56
  throw new VageError(`Push failed (your commit is saved locally): ${out.trim()}`);
57
57
  }
@@ -84,7 +84,7 @@ export async function planPublish(root, options) {
84
84
  if (!branch)
85
85
  throw new VageError("HEAD is detached. Check out a branch before publishing.");
86
86
  if (options.branch && options.branch !== branch) {
87
- throw new VageError(`You are on "${branch}", not "${options.branch}". Switch branches yourself first; VAGE JS won't do it for you.`);
87
+ throw new VageError(`You are on "${branch}", not "${options.branch}". Switch branches yourself first; vagejs won't do it for you.`);
88
88
  }
89
89
  }
90
90
  const origin = initRepo ? undefined : await gitRaw(cwd, ["remote", "get-url", "origin"]);
@@ -129,7 +129,7 @@ export async function planPublish(root, options) {
129
129
  addRemote: Boolean(options.remote && !existingRemote),
130
130
  files,
131
131
  extraFiles,
132
- message: options.message ?? "Publish site with VAGE JS",
132
+ message: options.message ?? "Publish site with vagejs",
133
133
  unpushedCommits,
134
134
  warnings,
135
135
  };
@@ -14,5 +14,5 @@ export function cloudflareRedirects(redirects) {
14
14
  });
15
15
  if (lines.length > maxStaticRules)
16
16
  throw new VageError(`_redirects would have ${lines.length} rules; Cloudflare Pages allows ${maxStaticRules}.`);
17
- return `# Generated by VAGE JS from redirects in vage.config.ts\n${lines.join("\n")}\n`;
17
+ return `# Generated by vagejs from redirects in vage.config.ts\n${lines.join("\n")}\n`;
18
18
  }
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  const SECRET_FILES = [
5
5
  [/(^|\/)\.env(\.(?!example$|sample$|template$)[^/]+)?$/, "environment file"],
6
6
  [/(^|\/)\.(dev\.vars|envrc)$/, "environment file"],
7
- [/(^|\/)(google|api-keys)\.json$/, "VAGE JS credentials file"],
7
+ [/(^|\/)(google|api-keys)\.json$/, "vagejs credentials file"],
8
8
  [/\.(pem|key|p12|pfx|jks|keystore)$/i, "private key or certificate store"],
9
9
  [/(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/, "SSH private key"],
10
10
  [/(^|\/)(credentials|client_secrets?|service[-_]?account[^/]*)\.json$/i, "credentials file"],
@@ -7,3 +7,5 @@ import type { Plugin } from "vite";
7
7
  */
8
8
  export declare function devAccessGuard(root: string, allowedFiles: Set<string>): Plugin;
9
9
  export declare function allowed(url: string, publicDir: string, allowedFiles: Set<string>): boolean;
10
+ /** A regular file whose real path (every folder resolved) is inside public/: no symlinked files or folders. */
11
+ export declare function insidePublic(file: string, publicDir: string): boolean;
package/dist/dev/guard.js CHANGED
@@ -45,11 +45,40 @@ export function allowed(url, publicDir, allowedFiles) {
45
45
  return true; // rendered pages (our middleware decides 200/404)
46
46
  if (allowedFiles.has(pathname))
47
47
  return true;
48
+ // Optimized images (images.optimize), served from the cache by the dev server.
49
+ if (/^\/_vage\/img\/[\w-]+\.webp$/.test(pathname))
50
+ return true;
51
+ // Generated files (seo.feeds, seo.markdownCopies) never exist on disk in dev. If a real file
52
+ // sits at that path, it's a project source (e.g. /src/pages/about.md): Vite would serve it, so refuse.
53
+ if (/^\/(?:[\w-]+\/)*(?:feed\.xml|[\w-]+\.md)$/.test(pathname)) {
54
+ if (fs.existsSync(path.join(path.dirname(publicDir), pathname)))
55
+ return false;
56
+ // A public/ file with that name is served by Vite, so apply the same rules as below.
57
+ const candidate = path.join(publicDir, pathname);
58
+ if (!fs.existsSync(candidate) && !isSymlink(candidate))
59
+ return true; // not in public/: generated by vagejs
60
+ return insidePublic(candidate, publicDir);
61
+ }
48
62
  const file = path.resolve(publicDir, `.${pathname}`);
49
63
  if (!file.startsWith(publicDir + path.sep))
50
64
  return false;
65
+ return insidePublic(file, publicDir);
66
+ }
67
+ function isSymlink(file) {
68
+ try {
69
+ return fs.lstatSync(file).isSymbolicLink();
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ /** A regular file whose real path (every folder resolved) is inside public/: no symlinked files or folders. */
76
+ export function insidePublic(file, publicDir) {
51
77
  try {
52
- return fs.lstatSync(file).isFile(); // lstat: symlinks are not served
78
+ if (!fs.lstatSync(file).isFile())
79
+ return false;
80
+ const realPublic = fs.realpathSync(publicDir);
81
+ return fs.realpathSync(file).startsWith(realPublic + path.sep);
53
82
  }
54
83
  catch {
55
84
  return false;
@@ -6,6 +6,8 @@ import { VageError } from "../core/errors.js";
6
6
  import { escapeHtml } from "../core/html.js";
7
7
  import { baseViteConfig, viteLoader } from "../core/loader.js";
8
8
  import { renderPage } from "../core/render.js";
9
+ import { cacheDir } from "../content/optimize.js";
10
+ import { collectPages } from "../core/pages.js";
9
11
  import { renderSite } from "../core/site.js";
10
12
  import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "../core/routes.js";
11
13
  import { devAccessGuard } from "./guard.js";
@@ -41,12 +43,27 @@ export async function startDevServer(root, options = {}) {
41
43
  const urlPath = req.url ?? "/";
42
44
  const pathname = urlPath.split(/[?#]/)[0];
43
45
  try {
44
- if (DISCOVERY_FILES[pathname]) {
46
+ const optimized = /^\/_vage\/img\/([\w-]+\.webp)$/.exec(pathname);
47
+ if (optimized) {
48
+ const file = path.join(cacheDir(root), optimized[1]);
49
+ if (!fs.existsSync(file))
50
+ return send(res, 404, plainPage("404 — Not found", `No optimized image at ${pathname}`));
51
+ res.setHeader("Content-Type", "image/webp");
52
+ return res.end(fs.readFileSync(file));
53
+ }
54
+ const isFeed = /^\/(?:[\w.-]+\/)*feed\.xml$/.test(pathname) && !pathname.includes("..");
55
+ const isCopy = /^\/(?:[\w-]+\/)*[\w-]+\.md$/.test(pathname);
56
+ if (DISCOVERY_FILES[pathname] || isFeed || isCopy) {
45
57
  // Built from the whole site (drafts excluded), exactly as `vage build` would write it.
46
58
  const content = (await renderSite(root, load)).files.get(pathname.slice(1));
47
- if (content === undefined)
59
+ if (content === undefined) {
60
+ // Feeds and Markdown copies exist only as generated files; never fall through to Vite,
61
+ // which would serve project sources (e.g. /src/pages/about.md).
62
+ if (isFeed || isCopy)
63
+ return send(res, 404, plainPage("404 — Not found", `No generated file at ${pathname}`));
48
64
  return next();
49
- res.setHeader("Content-Type", DISCOVERY_FILES[pathname]);
65
+ }
66
+ res.setHeader("Content-Type", DISCOVERY_FILES[pathname] ?? (isCopy ? "text/markdown; charset=utf-8" : "application/rss+xml; charset=utf-8"));
50
67
  return res.end(content);
51
68
  }
52
69
  // Leave asset requests (any extension other than .html) to Vite.
@@ -74,7 +91,8 @@ export async function startDevServer(root, options = {}) {
74
91
  return send(res, 404, await server.transformIndexHtml(urlPath, plainPage("404 — Not found", `No page for ${route}`)));
75
92
  }
76
93
  const file = entry?.file ?? notFound;
77
- const page = await renderPage({ root, file, route: entry ? route : "/404/", site, load, notFound: !entry });
94
+ const pages = await collectPages(root, site, load, { skipInvalid: true });
95
+ const page = await renderPage({ root, file, route: entry ? route : "/404/", site, load, notFound: !entry, pages });
78
96
  send(res, entry ? 200 : 404, await server.transformIndexHtml(urlPath, page.html));
79
97
  }
80
98
  catch (err) {
@@ -0,0 +1,21 @@
1
+ import type { SiteConfig } from "../core/config.js";
2
+ import type { SitePage } from "../core/site.js";
3
+ export interface FeedConfig {
4
+ under: string;
5
+ title?: string;
6
+ description?: string;
7
+ limit: number;
8
+ }
9
+ /** The feed's title: configured, else the site title. Used for the channel and the page links. */
10
+ export declare function feedTitle(site: SiteConfig, feed: {
11
+ title?: string;
12
+ under: string;
13
+ }): string;
14
+ /** Where a section's feed is published: "/blog/" -> "blog/feed.xml". */
15
+ export declare function feedPath(under: string): string;
16
+ /**
17
+ * RSS 2.0 for one section: its listed pages, newest datePublished first (undated last).
18
+ * Item descriptions are the page descriptions; dates come only from frontmatter, so the
19
+ * feed is the same on every build.
20
+ */
21
+ export declare function rssFeed(site: SiteConfig, feed: FeedConfig, pages: SitePage[], origin: string): string;
@@ -0,0 +1,68 @@
1
+ import { instant } from "../core/dates.js";
2
+ const XML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" };
3
+ const xml = (s) => s.replace(/[&<>"']/g, (c) => XML_ESCAPES[c]);
4
+ /** The feed's title: configured, else the site title. Used for the channel and the page links. */
5
+ export function feedTitle(site, feed) {
6
+ return feed.title ?? site.title ?? feed.under;
7
+ }
8
+ /** Where a section's feed is published: "/blog/" -> "blog/feed.xml". */
9
+ export function feedPath(under) {
10
+ return `${under.replace(/^\/+/, "")}feed.xml`;
11
+ }
12
+ /** RFC 822 date, as RSS requires: "Thu, 23 Oct 2026 00:00:00 GMT". */
13
+ function rfc822(iso) {
14
+ return new Date(iso.length === 10 ? `${iso}T00:00:00Z` : iso).toUTCString();
15
+ }
16
+ /**
17
+ * RSS 2.0 for one section: its listed pages, newest datePublished first (undated last).
18
+ * Item descriptions are the page descriptions; dates come only from frontmatter, so the
19
+ * feed is the same on every build.
20
+ */
21
+ export function rssFeed(site, feed, pages, origin) {
22
+ const items = pages
23
+ .filter((p) => p.route.startsWith(feed.under) && p.route !== feed.under)
24
+ .sort((a, b) => {
25
+ // Compare as instants: "2026-10-23T23:00:00-05:00" is after "2026-10-24".
26
+ const ta = instant(a.frontmatter.datePublished);
27
+ const tb = instant(b.frontmatter.datePublished);
28
+ if (ta === tb || (Number.isNaN(ta) && Number.isNaN(tb)))
29
+ return a.frontmatter.title.localeCompare(b.frontmatter.title);
30
+ return Number.isNaN(ta) ? 1 : Number.isNaN(tb) ? -1 : tb - ta;
31
+ })
32
+ .slice(0, feed.limit);
33
+ const section = pages.find((p) => p.route === feed.under);
34
+ // Same default as the <link rel="alternate"> title on the pages.
35
+ const title = feedTitle(site, feed);
36
+ const description = feed.description ?? section?.frontmatter.description ?? site.description ?? title;
37
+ const self = `${origin}/${feedPath(feed.under)}`;
38
+ const newest = items.find((p) => p.frontmatter.datePublished)?.frontmatter.datePublished; // items are sorted newest first
39
+ const body = items
40
+ .map((p) => {
41
+ const link = p.meta.canonical ?? `${origin}${p.route}`;
42
+ const date = p.frontmatter.datePublished;
43
+ return [
44
+ " <item>",
45
+ ` <title>${xml(p.frontmatter.title)}</title>`,
46
+ ` <link>${xml(link)}</link>`,
47
+ ` <guid isPermaLink="true">${xml(link)}</guid>`,
48
+ p.frontmatter.description ? ` <description>${xml(p.frontmatter.description)}</description>` : "",
49
+ date ? ` <pubDate>${rfc822(date)}</pubDate>` : "",
50
+ " </item>",
51
+ ]
52
+ .filter(Boolean)
53
+ .join("\n");
54
+ })
55
+ .join("\n");
56
+ return `<?xml version="1.0" encoding="UTF-8"?>
57
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
58
+ <channel>
59
+ <title>${xml(title)}</title>
60
+ <link>${xml(`${origin}${feed.under}`)}</link>
61
+ <description>${xml(description)}</description>
62
+ <language>${xml(site.lang)}</language>
63
+ <atom:link href="${xml(self)}" rel="self" type="application/rss+xml" />${newest ? `\n <lastBuildDate>${rfc822(newest)}</lastBuildDate>` : ""}
64
+ ${body}
65
+ </channel>
66
+ </rss>
67
+ `;
68
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Last commit date (ISO) of every file under src/pages, from one `git log` call.
3
+ * Returns undefined with a reason when dates would be wrong or unavailable: not a git
4
+ * repository, or a shallow clone (CI's default), where every file seems to change in
5
+ * the latest commit.
6
+ */
7
+ export declare function gitPageDates(root: string): {
8
+ dates?: Map<string, string>;
9
+ problem?: string;
10
+ };
@@ -0,0 +1,57 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ /**
5
+ * Last commit date (ISO) of every file under src/pages, from one `git log` call.
6
+ * Returns undefined with a reason when dates would be wrong or unavailable: not a git
7
+ * repository, or a shallow clone (CI's default), where every file seems to change in
8
+ * the latest commit.
9
+ */
10
+ export function gitPageDates(root) {
11
+ // core.quotepath=off: print non-ASCII file names as-is (default would quote "caf\303\251.md").
12
+ const git = (args) => execFileSync("git", ["-c", "core.quotepath=off", ...args], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
13
+ try {
14
+ git(["--version"]);
15
+ }
16
+ catch {
17
+ return { problem: "git isn't available" };
18
+ }
19
+ try {
20
+ if (git(["rev-parse", "--is-inside-work-tree"]).trim() !== "true")
21
+ return { problem: "the project isn't a git repository" };
22
+ }
23
+ catch {
24
+ return { problem: "the project isn't a git repository" };
25
+ }
26
+ try {
27
+ if (git(["rev-parse", "--is-shallow-repository"]).trim() === "true") {
28
+ return { problem: "this is a shallow git clone, so every page would get the latest commit's date (in GitHub Actions, use actions/checkout with fetch-depth: 0)" };
29
+ }
30
+ const top = git(["rev-parse", "--show-toplevel"]).trim();
31
+ // git prints real paths; compare against the project's real path (e.g. /var -> /private/var on macOS).
32
+ const real = fs.realpathSync(root);
33
+ // -z: NUL-separated, so file names with quotes, spaces or newlines come through exactly.
34
+ const out = git(["log", "-z", "--format=@@%cI", "--name-only", "--no-renames", "--", path.join(root, "src", "pages")]);
35
+ const dates = new Map();
36
+ let current = "";
37
+ for (const raw of out.split("\0")) {
38
+ const field = raw.replace(/^\n+/, "");
39
+ if (!field)
40
+ continue;
41
+ const header = /^@@(\S+)\n?([\s\S]*)$/.exec(field);
42
+ const name = header ? header[2] : field;
43
+ if (header)
44
+ current = header[1];
45
+ if (name && current) {
46
+ const rel = path.relative(real, path.join(top, name));
47
+ if (!dates.has(rel))
48
+ dates.set(rel, current); // newest first: keep the first date seen
49
+ }
50
+ }
51
+ return { dates };
52
+ }
53
+ catch (err) {
54
+ const stderr = String(err.stderr ?? "").trim().split("\n")[0];
55
+ return { problem: `reading the git history failed (${stderr || err.message.split("\n")[0]})` };
56
+ }
57
+ }
@@ -0,0 +1,13 @@
1
+ /** "/" -> "index.md", "/docs/cli/" -> "docs/cli.md". */
2
+ export declare function markdownCopyPath(route: string): string;
3
+ /**
4
+ * The page as Markdown, converted from its rendered, sanitized HTML: the copy contains
5
+ * exactly what the page shows (no comments, no raw HTML the sanitizer removed, the same
6
+ * links the link lock checked), with links and images made absolute.
7
+ */
8
+ export declare function htmlToMarkdown(html: string, options: {
9
+ route: string;
10
+ origin?: string;
11
+ }): Promise<string>;
12
+ /** The copy: the page's own H1 (or the title), the summary, then the content. */
13
+ export declare function markdownCopy(title: string, description: string | undefined, markdown: string): string;