vagejs 0.0.0-stage → 0.2.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 (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -2
  3. package/dist/audit/a11y.d.ts +13 -0
  4. package/dist/audit/a11y.js +33 -0
  5. package/dist/audit/audit.d.ts +31 -0
  6. package/dist/audit/audit.js +143 -0
  7. package/dist/audit/budgets.d.ts +22 -0
  8. package/dist/audit/budgets.js +106 -0
  9. package/dist/audit/lighthouse.d.ts +3 -0
  10. package/dist/audit/lighthouse.js +48 -0
  11. package/dist/audit/remote.d.ts +65 -0
  12. package/dist/audit/remote.js +123 -0
  13. package/dist/audit/vitals.d.ts +28 -0
  14. package/dist/audit/vitals.js +18 -0
  15. package/dist/checks/check.d.ts +31 -0
  16. package/dist/checks/check.js +434 -0
  17. package/dist/checks/integrity.d.ts +36 -0
  18. package/dist/checks/integrity.js +145 -0
  19. package/dist/checks/links.d.ts +18 -0
  20. package/dist/checks/links.js +47 -0
  21. package/dist/checks/verify.d.ts +35 -0
  22. package/dist/checks/verify.js +167 -0
  23. package/dist/cli/create.d.ts +10 -0
  24. package/dist/cli/create.js +39 -0
  25. package/dist/cli/index.d.ts +2 -0
  26. package/dist/cli/index.js +710 -0
  27. package/dist/cli/info.d.ts +82 -0
  28. package/dist/cli/info.js +100 -0
  29. package/dist/content/images.d.ts +23 -0
  30. package/dist/content/images.js +96 -0
  31. package/dist/content/markdown.d.ts +28 -0
  32. package/dist/content/markdown.js +56 -0
  33. package/dist/content/sanitize.d.ts +16 -0
  34. package/dist/content/sanitize.js +57 -0
  35. package/dist/core/build.d.ts +25 -0
  36. package/dist/core/build.js +54 -0
  37. package/dist/core/clone.d.ts +6 -0
  38. package/dist/core/clone.js +16 -0
  39. package/dist/core/config.d.ts +180 -0
  40. package/dist/core/config.js +260 -0
  41. package/dist/core/errors.d.ts +7 -0
  42. package/dist/core/errors.js +12 -0
  43. package/dist/core/html.d.ts +24 -0
  44. package/dist/core/html.js +42 -0
  45. package/dist/core/loader.d.ts +10 -0
  46. package/dist/core/loader.js +16 -0
  47. package/dist/core/pages.d.ts +40 -0
  48. package/dist/core/pages.js +93 -0
  49. package/dist/core/project-config.d.ts +3 -0
  50. package/dist/core/project-config.js +14 -0
  51. package/dist/core/render.d.ts +53 -0
  52. package/dist/core/render.js +104 -0
  53. package/dist/core/routes.d.ts +24 -0
  54. package/dist/core/routes.js +70 -0
  55. package/dist/core/site.d.ts +42 -0
  56. package/dist/core/site.js +182 -0
  57. package/dist/core/validate.d.ts +12 -0
  58. package/dist/core/validate.js +36 -0
  59. package/dist/deploy/cloudflare-pages.d.ts +20 -0
  60. package/dist/deploy/cloudflare-pages.js +42 -0
  61. package/dist/deploy/cloudflare-workers.d.ts +59 -0
  62. package/dist/deploy/cloudflare-workers.js +223 -0
  63. package/dist/deploy/git.d.ts +20 -0
  64. package/dist/deploy/git.js +55 -0
  65. package/dist/deploy/github-pages.d.ts +33 -0
  66. package/dist/deploy/github-pages.js +99 -0
  67. package/dist/deploy/headers.d.ts +11 -0
  68. package/dist/deploy/headers.js +103 -0
  69. package/dist/deploy/publish.d.ts +45 -0
  70. package/dist/deploy/publish.js +148 -0
  71. package/dist/deploy/redirects.d.ts +7 -0
  72. package/dist/deploy/redirects.js +18 -0
  73. package/dist/deploy/secrets.d.ts +6 -0
  74. package/dist/deploy/secrets.js +66 -0
  75. package/dist/dev/guard.d.ts +9 -0
  76. package/dist/dev/guard.js +57 -0
  77. package/dist/dev/listen.d.ts +6 -0
  78. package/dist/dev/listen.js +13 -0
  79. package/dist/dev/preview.d.ts +6 -0
  80. package/dist/dev/preview.js +72 -0
  81. package/dist/dev/server.d.ts +8 -0
  82. package/dist/dev/server.js +104 -0
  83. package/dist/discovery/crawlers.d.ts +13 -0
  84. package/dist/discovery/crawlers.js +2 -0
  85. package/dist/discovery/llms.d.ts +12 -0
  86. package/dist/discovery/llms.js +19 -0
  87. package/dist/discovery/robots.d.ts +8 -0
  88. package/dist/discovery/robots.js +40 -0
  89. package/dist/discovery/sitemap.d.ts +8 -0
  90. package/dist/discovery/sitemap.js +11 -0
  91. package/dist/guidelines/monitor.d.ts +55 -0
  92. package/dist/guidelines/monitor.js +126 -0
  93. package/dist/guidelines/pack.d.ts +41 -0
  94. package/dist/guidelines/pack.js +13 -0
  95. package/dist/guidelines/rule-pack.json +611 -0
  96. package/dist/index.d.ts +14 -0
  97. package/dist/index.js +12 -0
  98. package/dist/integrations/api-keys.d.ts +26 -0
  99. package/dist/integrations/api-keys.js +65 -0
  100. package/dist/integrations/ga4-data.d.ts +13 -0
  101. package/dist/integrations/ga4-data.js +39 -0
  102. package/dist/integrations/google-auth.d.ts +47 -0
  103. package/dist/integrations/google-auth.js +157 -0
  104. package/dist/integrations/http.d.ts +15 -0
  105. package/dist/integrations/http.js +40 -0
  106. package/dist/integrations/indexnow.d.ts +18 -0
  107. package/dist/integrations/indexnow.js +38 -0
  108. package/dist/integrations/search-console.d.ts +25 -0
  109. package/dist/integrations/search-console.js +39 -0
  110. package/dist/integrations/tags.d.ts +22 -0
  111. package/dist/integrations/tags.js +67 -0
  112. package/dist/seo/head.d.ts +20 -0
  113. package/dist/seo/head.js +85 -0
  114. package/dist/seo/jsonld.d.ts +35 -0
  115. package/dist/seo/jsonld.js +31 -0
  116. package/dist/seo/urls.d.ts +3 -0
  117. package/dist/seo/urls.js +10 -0
  118. package/package.json +81 -4
  119. package/templates/starter/.env.example +12 -0
  120. package/templates/starter/.github/dependabot.yml +11 -0
  121. package/templates/starter/.github/workflows/ci.yml +30 -0
  122. package/templates/starter/.github/workflows/verify.yml +32 -0
  123. package/templates/starter/AGENTS.md +166 -0
  124. package/templates/starter/CLAUDE.md +3 -0
  125. package/templates/starter/_gitignore +8 -0
  126. package/templates/starter/links.lock.json +4 -0
  127. package/templates/starter/package.json +16 -0
  128. package/templates/starter/public/apple-touch-icon.png +0 -0
  129. package/templates/starter/public/favicon.ico +0 -0
  130. package/templates/starter/public/favicon.svg +1 -0
  131. package/templates/starter/src/layouts/blog.ts +13 -0
  132. package/templates/starter/src/layouts/default.ts +38 -0
  133. package/templates/starter/src/pages/404.md +7 -0
  134. package/templates/starter/src/pages/about.md +8 -0
  135. package/templates/starter/src/pages/blog/first-post.md +10 -0
  136. package/templates/starter/src/pages/blog/index.md +7 -0
  137. package/templates/starter/src/pages/index.md +8 -0
  138. package/templates/starter/vage.config.ts +38 -0
@@ -0,0 +1,45 @@
1
+ import { type ChangedFile } from "./git.js";
2
+ export interface PublishPlan {
3
+ root: string;
4
+ /** True when `git init` will run (the folder is not a repository yet). */
5
+ initRepo: boolean;
6
+ branch: string;
7
+ /** Remote URL that will be pushed to, if any. */
8
+ remote?: string;
9
+ /** True when `--remote` will be added as "origin". */
10
+ addRemote: boolean;
11
+ files: ChangedFile[];
12
+ /** Files vagejs will create (they do not exist yet). */
13
+ extraFiles: Record<string, string>;
14
+ message: string;
15
+ /** Local commits not yet on the remote (before this commit). */
16
+ unpushedCommits: number;
17
+ warnings: string[];
18
+ }
19
+ export interface PublishResult {
20
+ plan: PublishPlan;
21
+ status: "published" | "committed" | "aborted" | "up-to-date";
22
+ commit?: string;
23
+ /** Set only after `git ls-remote` shows the remote branch at our commit. */
24
+ confirmedRemote?: {
25
+ url: string;
26
+ branch: string;
27
+ sha: string;
28
+ };
29
+ }
30
+ export interface PublishOptions {
31
+ remote?: string;
32
+ branch?: string;
33
+ message?: string;
34
+ /** Receives the full plan; nothing is changed unless it resolves true. */
35
+ confirm: (plan: PublishPlan) => Promise<boolean>;
36
+ /** Files to create if missing, before committing (e.g. a deploy workflow): relative path -> content, given the branch. */
37
+ extraFiles?: (branch: string) => Record<string, string>;
38
+ }
39
+ /**
40
+ * Safe commit-and-push: runs check and build, refuses secrets and build
41
+ * output, shows the plan, asks for confirmation, never force-pushes, and
42
+ * reports success only after the remote confirms the commit.
43
+ */
44
+ export declare function publish(root: string, options: PublishOptions): Promise<PublishResult>;
45
+ export declare function planPublish(root: string, options: Omit<PublishOptions, "confirm">): Promise<PublishPlan>;
@@ -0,0 +1,148 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { check } from "../checks/check.js";
5
+ import { build } from "../core/build.js";
6
+ import { VageError } from "../core/errors.js";
7
+ import { assertBranchName, assertGitRemote } from "../core/validate.js";
8
+ import { changedFiles, git, gitRaw } from "./git.js";
9
+ import { findSecrets } from "./secrets.js";
10
+ const BLOCKED_DIRS = ["node_modules/", "dist/"];
11
+ /**
12
+ * Safe commit-and-push: runs check and build, refuses secrets and build
13
+ * output, shows the plan, asks for confirmation, never force-pushes, and
14
+ * reports success only after the remote confirms the commit.
15
+ */
16
+ export async function publish(root, options) {
17
+ const plan = await planPublish(root, options);
18
+ const nothingToCommit = plan.files.length === 0;
19
+ if (nothingToCommit && plan.unpushedCommits === 0 && !plan.initRepo)
20
+ return { plan, status: "up-to-date" };
21
+ if (!(await options.confirm(plan)))
22
+ return { plan, status: "aborted" };
23
+ const cwd = plan.root;
24
+ if (plan.initRepo)
25
+ await git(cwd, ["init", "-b", plan.branch]);
26
+ if (plan.addRemote)
27
+ await git(cwd, ["remote", "add", "--", "origin", plan.remote]);
28
+ for (const [rel, content] of Object.entries(plan.extraFiles)) {
29
+ fs.mkdirSync(path.dirname(path.join(cwd, rel)), { recursive: true });
30
+ fs.writeFileSync(path.join(cwd, rel), content);
31
+ }
32
+ let commit;
33
+ if (!nothingToCommit) {
34
+ await git(cwd, ["add", "-A"]);
35
+ // Re-check what is actually staged: the tree may have changed since the plan was shown.
36
+ const staged = (await git(cwd, ["diff", "--cached", "--name-only", "-z"])).split("\0").filter(Boolean);
37
+ const planned = new Set(plan.files.map((f) => f.path));
38
+ const unexpected = staged.filter((f) => !planned.has(f));
39
+ if (unexpected.length) {
40
+ await git(cwd, ["reset", "-q"]);
41
+ throw new VageError(`Files changed after the plan was shown and were not committed: ${unexpected.join(", ")}. Re-run to review.`);
42
+ }
43
+ await git(cwd, ["commit", "-q", "-m", plan.message]);
44
+ commit = await git(cwd, ["rev-parse", "HEAD"]);
45
+ }
46
+ commit ??= await git(cwd, ["rev-parse", "HEAD"]);
47
+ if (!plan.remote)
48
+ return { plan, status: "committed", commit };
49
+ const push = await gitRaw(cwd, ["push", "-u", "origin", "--", plan.branch]);
50
+ if (push.code !== 0) {
51
+ const out = push.stderr + push.stdout;
52
+ if (/rejected|non-fast-forward|fetch first/i.test(out)) {
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. vagejs never force-pushes.`);
55
+ }
56
+ throw new VageError(`Push failed (your commit is saved locally): ${out.trim()}`);
57
+ }
58
+ const remoteSha = (await git(cwd, ["ls-remote", "origin", `refs/heads/${plan.branch}`])).split(/\s/)[0];
59
+ if (remoteSha !== commit) {
60
+ throw new VageError(`Push finished but origin/${plan.branch} is at ${remoteSha || "nothing"}, not ${commit}. Check the remote before retrying.`);
61
+ }
62
+ return { plan, status: "published", commit, confirmedRemote: { url: plan.remote, branch: plan.branch, sha: remoteSha } };
63
+ }
64
+ export async function planPublish(root, options) {
65
+ root = path.resolve(root);
66
+ if (options.branch)
67
+ assertBranchName(options.branch);
68
+ if (options.remote)
69
+ assertGitRemote(options.remote);
70
+ const checked = await check(root);
71
+ if (checked.errors > 0)
72
+ throw new VageError(`vage check found ${checked.errors} error(s). Fix them (run "vage check") before publishing.`);
73
+ await build(root);
74
+ const top = await gitRaw(root, ["rev-parse", "--show-toplevel"]);
75
+ const initRepo = top.code !== 0;
76
+ const cwd = initRepo ? root : fs.realpathSync(top.stdout.trim());
77
+ const warnings = [];
78
+ let branch;
79
+ if (initRepo) {
80
+ branch = options.branch ?? "main";
81
+ }
82
+ else {
83
+ branch = await git(cwd, ["branch", "--show-current"]);
84
+ if (!branch)
85
+ throw new VageError("HEAD is detached. Check out a branch before publishing.");
86
+ if (options.branch && options.branch !== branch) {
87
+ throw new VageError(`You are on "${branch}", not "${options.branch}". Switch branches yourself first; vagejs won't do it for you.`);
88
+ }
89
+ }
90
+ const origin = initRepo ? undefined : await gitRaw(cwd, ["remote", "get-url", "origin"]);
91
+ const existingRemote = origin?.code === 0 ? origin.stdout.trim() : undefined;
92
+ if (options.remote && existingRemote && options.remote !== existingRemote) {
93
+ throw new VageError(`origin is already ${existingRemote}. Change it yourself with "git remote set-url" if that is intended.`);
94
+ }
95
+ const remote = existingRemote ?? options.remote;
96
+ if (!remote)
97
+ warnings.push('No remote: changes will be committed locally only. Pass --remote <git-url> to push (e.g. a repo created with "gh repo create").');
98
+ const files = initRepo ? await filesForNewRepo(root) : await changedFiles(cwd);
99
+ const blocked = files.filter((f) => BLOCKED_DIRS.some((d) => f.path.startsWith(d) || f.path.includes(`/${d}`)));
100
+ if (blocked.length) {
101
+ throw new VageError(`Refusing to commit build output or dependencies (${blocked[0].path}, …). Add node_modules/ and dist/ to .gitignore.`);
102
+ }
103
+ const secrets = findSecrets(cwd, files.filter((f) => !f.status.includes("D")).map((f) => f.path));
104
+ if (secrets.length) {
105
+ throw new VageError(`Refusing to commit possible secrets:\n${secrets.map((s) => ` ${s.path}: ${s.reason}`).join("\n")}\n` +
106
+ "Add them to .gitignore (and rotate any real credential that was exposed). False positive? Commit manually with git.");
107
+ }
108
+ const pkgPath = path.join(root, "package.json");
109
+ if (fs.existsSync(pkgPath)) {
110
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
111
+ const local = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }).filter(([, v]) => String(v).startsWith("file:"));
112
+ if (local.length)
113
+ warnings.push(`package.json uses local "file:" dependencies (${local.map(([k]) => k).join(", ")}); CI and other machines cannot install them.`);
114
+ }
115
+ let unpushedCommits = 0;
116
+ if (!initRepo && remote && (await gitRaw(cwd, ["rev-parse", "HEAD"])).code === 0) {
117
+ const tracking = await gitRaw(cwd, ["rev-list", "--count", `origin/${branch}..HEAD`]);
118
+ unpushedCommits = tracking.code === 0 ? Number(tracking.stdout.trim()) : Number(await git(cwd, ["rev-list", "--count", "HEAD"]));
119
+ }
120
+ // Generated files are only created when missing, never overwritten; they are part of the commit.
121
+ const extraFiles = Object.fromEntries(Object.entries(options.extraFiles?.(branch) ?? {}).filter(([rel]) => !fs.existsSync(path.join(cwd, rel))));
122
+ for (const rel of Object.keys(extraFiles))
123
+ files.push({ status: "??", path: rel });
124
+ return {
125
+ root: cwd,
126
+ initRepo,
127
+ branch,
128
+ remote,
129
+ addRemote: Boolean(options.remote && !existingRemote),
130
+ files,
131
+ extraFiles,
132
+ message: options.message ?? "Publish site with vagejs",
133
+ unpushedCommits,
134
+ warnings,
135
+ };
136
+ }
137
+ /** Lists what `git add -A` would stage in a folder that is not a repo yet, honoring .gitignore, without touching it. */
138
+ async function filesForNewRepo(root) {
139
+ const gitDir = fs.mkdtempSync(path.join(os.tmpdir(), "vage-plan-"));
140
+ try {
141
+ await git(root, ["--git-dir", gitDir, "--work-tree", root, "init", "-q"]);
142
+ const out = await git(root, ["--git-dir", gitDir, "--work-tree", root, "ls-files", "--others", "--exclude-standard", "-z"]);
143
+ return out.split("\0").filter(Boolean).sort().map((p) => ({ status: "??", path: p }));
144
+ }
145
+ finally {
146
+ fs.rmSync(gitDir, { recursive: true, force: true });
147
+ }
148
+ }
@@ -0,0 +1,7 @@
1
+ import type { SiteRedirect } from "../core/site.js";
2
+ /**
3
+ * Cloudflare Pages `_redirects`: real 301s for configured redirects. The
4
+ * meta-refresh pages stay as a fallback for hosts without redirect support.
5
+ * Both "/old" and "/old/" are listed so either form redirects.
6
+ */
7
+ export declare function cloudflareRedirects(redirects: SiteRedirect[]): string;
@@ -0,0 +1,18 @@
1
+ import { VageError } from "../core/errors.js";
2
+ import { ruleData } from "../guidelines/pack.js";
3
+ // https://developers.cloudflare.com/pages/configuration/redirects/ (rule pack "cloudflare.redirects")
4
+ const { maxStaticRules } = ruleData("cloudflare.redirects");
5
+ /**
6
+ * Cloudflare Pages `_redirects`: real 301s for configured redirects. The
7
+ * meta-refresh pages stay as a fallback for hosts without redirect support.
8
+ * Both "/old" and "/old/" are listed so either form redirects.
9
+ */
10
+ export function cloudflareRedirects(redirects) {
11
+ const lines = redirects.flatMap((r) => {
12
+ const sources = r.from === "/" ? ["/"] : [r.from, r.from.replace(/\/$/, "")];
13
+ return sources.map((from) => `${from} ${r.to} 301`);
14
+ });
15
+ if (lines.length > maxStaticRules)
16
+ throw new VageError(`_redirects would have ${lines.length} rules; Cloudflare Pages allows ${maxStaticRules}.`);
17
+ return `# Generated by vagejs from redirects in vage.config.ts\n${lines.join("\n")}\n`;
18
+ }
@@ -0,0 +1,6 @@
1
+ export interface SecretHit {
2
+ path: string;
3
+ reason: string;
4
+ }
5
+ /** Scans files about to be committed. Never returns the secret value itself. */
6
+ export declare function findSecrets(root: string, files: string[]): SecretHit[];
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /** File names that commonly hold credentials. */
4
+ const SECRET_FILES = [
5
+ [/(^|\/)\.env(\.(?!example$|sample$|template$)[^/]+)?$/, "environment file"],
6
+ [/(^|\/)\.(dev\.vars|envrc)$/, "environment file"],
7
+ [/(^|\/)(google|api-keys)\.json$/, "vagejs credentials file"],
8
+ [/\.(pem|key|p12|pfx|jks|keystore)$/i, "private key or certificate store"],
9
+ [/(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/, "SSH private key"],
10
+ [/(^|\/)(credentials|client_secrets?|service[-_]?account[^/]*)\.json$/i, "credentials file"],
11
+ [/(^|\/)\.(netrc|pgpass)$/, "credentials file"],
12
+ ];
13
+ /** Well-known token formats. Matching is a strong hint, not proof; false positives are possible. */
14
+ const SECRET_CONTENT = [
15
+ [/-----BEGIN (?:[A-Z]+ )?PRIVATE KEY(?: BLOCK)?-----/, "private key"],
16
+ [/\bAKIA[0-9A-Z]{16}\b/, "AWS access key ID"],
17
+ [/\bgh[pousr]_[A-Za-z0-9]{36,}\b/, "GitHub token"],
18
+ [/\bgithub_pat_[A-Za-z0-9_]{22,}\b/, "GitHub fine-grained token"],
19
+ [/\bAIza[0-9A-Za-z_-]{35}\b/, "Google API key"],
20
+ [/\bGOCSPX-[A-Za-z0-9_-]{20,}/, "Google OAuth client secret"],
21
+ [/\b1\/\/0[A-Za-z0-9_-]{20,}/, "Google OAuth refresh token"],
22
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, "Slack token"],
23
+ [/\bsk_live_[A-Za-z0-9]{16,}\b/, "Stripe live key"],
24
+ [/\bsk-ant-[A-Za-z0-9_-]{20,}\b/, "Anthropic API key"],
25
+ [/\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/, "OpenAI-style API key"],
26
+ [/_authToken\s*=\s*\S+/, "npm auth token"],
27
+ ];
28
+ /** Text files above this are flagged for manual review instead of being skipped silently. */
29
+ const MAX_SCAN_BYTES = 20 * 1024 * 1024;
30
+ /** Scans files about to be committed. Never returns the secret value itself. */
31
+ export function findSecrets(root, files) {
32
+ const hits = [];
33
+ for (const rel of files) {
34
+ const name = SECRET_FILES.find(([re]) => re.test(rel));
35
+ if (name) {
36
+ hits.push({ path: rel, reason: name[1] });
37
+ continue;
38
+ }
39
+ const file = path.join(root, rel);
40
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile())
41
+ continue;
42
+ if (isBinary(file))
43
+ continue;
44
+ if (fs.statSync(file).size > MAX_SCAN_BYTES) {
45
+ hits.push({ path: rel, reason: "text file too large to scan for secrets; review it manually" });
46
+ continue;
47
+ }
48
+ const buf = fs.readFileSync(file);
49
+ const text = buf.toString("utf8");
50
+ const content = SECRET_CONTENT.find(([re]) => re.test(text));
51
+ if (content)
52
+ hits.push({ path: rel, reason: `looks like a ${content[1]}` });
53
+ }
54
+ return hits;
55
+ }
56
+ function isBinary(file) {
57
+ const fd = fs.openSync(file, "r");
58
+ try {
59
+ const head = Buffer.alloc(8000);
60
+ const n = fs.readSync(fd, head, 0, head.length, 0);
61
+ return head.subarray(0, n).includes(0);
62
+ }
63
+ finally {
64
+ fs.closeSync(fd);
65
+ }
66
+ }
@@ -0,0 +1,9 @@
1
+ import type { Plugin } from "vite";
2
+ /**
3
+ * Runs before Vite's own middleware. The dev server only needs to serve
4
+ * rendered pages, discovery files, public/ files and Vite's live-reload
5
+ * client, so everything else (package.json, vage.config.ts, page sources,
6
+ * layouts, /@fs/ paths, ?raw/?import) is a 404. This matters with --host.
7
+ */
8
+ export declare function devAccessGuard(root: string, allowedFiles: Set<string>): Plugin;
9
+ export declare function allowed(url: string, publicDir: string, allowedFiles: Set<string>): boolean;
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /** Vite's source-transform endpoints: they would expose project files as text or modules. */
4
+ const SOURCE_QUERY = /[?&](raw|import|url|inline|worker|sharedworker|direct|html-proxy)\b/;
5
+ const VITE_CLIENT = "/node_modules/vite/dist/client/";
6
+ /**
7
+ * Runs before Vite's own middleware. The dev server only needs to serve
8
+ * rendered pages, discovery files, public/ files and Vite's live-reload
9
+ * client, so everything else (package.json, vage.config.ts, page sources,
10
+ * layouts, /@fs/ paths, ?raw/?import) is a 404. This matters with --host.
11
+ */
12
+ export function devAccessGuard(root, allowedFiles) {
13
+ const publicDir = path.join(root, "public");
14
+ return {
15
+ name: "vage:dev-access-guard",
16
+ configureServer(server) {
17
+ server.middlewares.use((req, res, next) => {
18
+ if (allowed(req.url ?? "/", publicDir, allowedFiles))
19
+ return next();
20
+ res.statusCode = 404;
21
+ res.end("Not found");
22
+ });
23
+ },
24
+ };
25
+ }
26
+ export function allowed(url, publicDir, allowedFiles) {
27
+ const [rawPath = "/", query = ""] = url.split("?");
28
+ let pathname;
29
+ try {
30
+ pathname = decodeURIComponent(rawPath);
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ if (pathname === "/@vite/client" || pathname === "/@vite/env" || pathname.startsWith("/__vite"))
36
+ return true;
37
+ if (pathname.startsWith(VITE_CLIENT) || (pathname.startsWith("/@fs/") && pathname.includes(VITE_CLIENT)))
38
+ return !pathname.includes("..");
39
+ if (SOURCE_QUERY.test(`?${query}`))
40
+ return false;
41
+ if (pathname.startsWith("/@"))
42
+ return false; // /@fs/, /@id/ and other internals
43
+ const ext = path.posix.extname(pathname);
44
+ if (!ext || ext === ".html")
45
+ return true; // rendered pages (our middleware decides 200/404)
46
+ if (allowedFiles.has(pathname))
47
+ return true;
48
+ const file = path.resolve(publicDir, `.${pathname}`);
49
+ if (!file.startsWith(publicDir + path.sep))
50
+ return false;
51
+ try {
52
+ return fs.lstatSync(file).isFile(); // lstat: symlinks are not served
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
@@ -0,0 +1,6 @@
1
+ export interface ServeOptions {
2
+ port?: number;
3
+ host?: string | boolean;
4
+ }
5
+ /** Runs a Vite listen() call and turns a port conflict into an actionable message. */
6
+ export declare function listenOrExplain<T>(listen: () => Promise<T>, port: number | undefined): Promise<T>;
@@ -0,0 +1,13 @@
1
+ import { VageError } from "../core/errors.js";
2
+ /** Runs a Vite listen() call and turns a port conflict into an actionable message. */
3
+ export async function listenOrExplain(listen, port) {
4
+ try {
5
+ return await listen();
6
+ }
7
+ catch (err) {
8
+ if (/already in use|EADDRINUSE/i.test(err.message)) {
9
+ throw new VageError(`Port ${port} is already in use. Stop the process using it, or choose another port with --port <number>.`);
10
+ }
11
+ throw err;
12
+ }
13
+ }
@@ -0,0 +1,6 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import { type PreviewServer } from "vite";
3
+ import { type ServeOptions } from "./listen.js";
4
+ /** Serves the production build output from dist/. */
5
+ export declare function startPreviewServer(root: string, options?: ServeOptions): Promise<PreviewServer>;
6
+ export declare function redirect(res: ServerResponse, location: string): void;
@@ -0,0 +1,72 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { preview } from "vite";
4
+ import { OUT_DIR } from "../core/build.js";
5
+ import { VageError } from "../core/errors.js";
6
+ import { listenOrExplain } from "./listen.js";
7
+ /** Serves the production build output from dist/. */
8
+ export async function startPreviewServer(root, options = {}) {
9
+ root = path.resolve(root);
10
+ if (!fs.existsSync(path.join(root, OUT_DIR))) {
11
+ throw new VageError(`No build output found in ${OUT_DIR}/. Run "vage build" first.`);
12
+ }
13
+ return listenOrExplain(() => preview({
14
+ root,
15
+ configFile: false,
16
+ logLevel: "warn",
17
+ appType: "mpa",
18
+ build: { outDir: OUT_DIR },
19
+ plugins: [staticHostBehavior(path.join(root, OUT_DIR))],
20
+ preview: { port: options.port, strictPort: options.port !== undefined, host: options.host },
21
+ }), options.port);
22
+ }
23
+ /**
24
+ * Mimics common static hosts: "/about" redirects to "/about/" when
25
+ * about/index.html exists, and unknown paths get 404.html with a 404 status.
26
+ */
27
+ function staticHostBehavior(outDir) {
28
+ const notFound = (res) => {
29
+ const page = path.join(outDir, "404.html");
30
+ res.statusCode = 404;
31
+ if (!fs.existsSync(page))
32
+ return res.end("Not found");
33
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
34
+ res.end(fs.readFileSync(page));
35
+ };
36
+ return {
37
+ name: "vage:static-host",
38
+ configurePreviewServer(server) {
39
+ server.middlewares.use((req, res, next) => {
40
+ const [rawPath = "/", query] = (req.url ?? "/").split("?");
41
+ let decoded;
42
+ try {
43
+ decoded = decodeURIComponent(rawPath);
44
+ }
45
+ catch {
46
+ res.statusCode = 400;
47
+ return res.end("Bad request");
48
+ }
49
+ // Resolve inside dist/ only: no traversal, no probing for files elsewhere.
50
+ const file = path.resolve(outDir, `.${decoded}`);
51
+ if (file !== outDir && !file.startsWith(outDir + path.sep))
52
+ return notFound(res);
53
+ const rel = path.relative(outDir, file).split(path.sep).join("/");
54
+ if (!decoded.endsWith("/") && !path.extname(decoded) && isFile(path.join(file, "index.html"))) {
55
+ // Built from the normalized path, so "//evil.example" can't become a protocol-relative redirect.
56
+ return redirect(res, `/${rel ? `${encodeURI(rel)}/` : ""}${query ? `?${query}` : ""}`);
57
+ }
58
+ if (isFile(decoded.endsWith("/") ? path.join(file, "index.html") : file))
59
+ return next();
60
+ notFound(res);
61
+ });
62
+ },
63
+ };
64
+ }
65
+ function isFile(file) {
66
+ return fs.existsSync(file) && fs.statSync(file).isFile();
67
+ }
68
+ export function redirect(res, location) {
69
+ res.statusCode = 301;
70
+ res.setHeader("Location", location);
71
+ res.end();
72
+ }
@@ -0,0 +1,8 @@
1
+ import { type ViteDevServer } from "vite";
2
+ import { type ServeOptions } from "./listen.js";
3
+ /**
4
+ * Starts the dev server. Pages are rendered on every request, so new and
5
+ * edited pages show up without a restart; any change under src/ or to the
6
+ * config triggers a full browser reload.
7
+ */
8
+ export declare function startDevServer(root: string, options?: ServeOptions): Promise<ViteDevServer>;
@@ -0,0 +1,104 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createServer } from "vite";
4
+ import { findConfigFile, loadConfig } from "../core/config.js";
5
+ import { VageError } from "../core/errors.js";
6
+ import { escapeHtml } from "../core/html.js";
7
+ import { baseViteConfig, viteLoader } from "../core/loader.js";
8
+ import { renderPage } from "../core/render.js";
9
+ import { collectPages } from "../core/pages.js";
10
+ import { renderSite } from "../core/site.js";
11
+ import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "../core/routes.js";
12
+ import { devAccessGuard } from "./guard.js";
13
+ import { listenOrExplain } from "./listen.js";
14
+ import { redirect } from "./preview.js";
15
+ /**
16
+ * Starts the dev server. Pages are rendered on every request, so new and
17
+ * edited pages show up without a restart; any change under src/ or to the
18
+ * config triggers a full browser reload.
19
+ */
20
+ export async function startDevServer(root, options = {}) {
21
+ // Vite reports watcher paths with symlinks resolved, so compare against the real path.
22
+ root = path.resolve(root);
23
+ if (!fs.existsSync(root))
24
+ throw new VageError(`Project directory not found: ${root}`);
25
+ root = fs.realpathSync(root);
26
+ const pagesDir = path.join(root, "src", "pages");
27
+ const srcDir = path.join(root, "src") + path.sep;
28
+ const server = await createServer({
29
+ ...baseViteConfig(root),
30
+ plugins: [devAccessGuard(root, new Set(Object.keys(DISCOVERY_FILES)))],
31
+ server: { port: options.port, strictPort: options.port !== undefined, host: options.host },
32
+ });
33
+ const load = viteLoader(server);
34
+ server.watcher.on("all", (_event, file) => {
35
+ if (file.startsWith(srcDir) || file === findConfigFile(root)) {
36
+ server.ws.send({ type: "full-reload", path: "*" });
37
+ }
38
+ });
39
+ server.middlewares.use(async (req, res, next) => {
40
+ if (req.method !== "GET" && req.method !== "HEAD")
41
+ return next();
42
+ const urlPath = req.url ?? "/";
43
+ const pathname = urlPath.split(/[?#]/)[0];
44
+ try {
45
+ if (DISCOVERY_FILES[pathname]) {
46
+ // Built from the whole site (drafts excluded), exactly as `vage build` would write it.
47
+ const content = (await renderSite(root, load)).files.get(pathname.slice(1));
48
+ if (content === undefined)
49
+ return next();
50
+ res.setHeader("Content-Type", DISCOVERY_FILES[pathname]);
51
+ return res.end(content);
52
+ }
53
+ // Leave asset requests (any extension other than .html) to Vite.
54
+ if (/\.[a-z0-9]+$/i.test(pathname) && !pathname.endsWith(".html"))
55
+ return next();
56
+ const site = await loadConfig(root, load);
57
+ let route;
58
+ try {
59
+ route = normalizeRequestPath(urlPath);
60
+ }
61
+ catch {
62
+ res.statusCode = 400;
63
+ return res.end("Bad request");
64
+ }
65
+ const target = Object.entries(site.redirects).find(([from]) => normalizeRequestPath(from) === route)?.[1];
66
+ if (target)
67
+ return redirect(res, target);
68
+ const entry = discoverRoutes(pagesDir).find((r) => r.route === route);
69
+ // Match production preview: "/about" -> "/about/".
70
+ if (entry && !pathname.endsWith("/") && !pathname.endsWith(".html")) {
71
+ return redirect(res, entry.route + urlPath.slice(pathname.length));
72
+ }
73
+ const notFound = findNotFoundPage(pagesDir);
74
+ if (!entry && !notFound) {
75
+ return send(res, 404, await server.transformIndexHtml(urlPath, plainPage("404 — Not found", `No page for ${route}`)));
76
+ }
77
+ const file = entry?.file ?? notFound;
78
+ const pages = await collectPages(root, site, load, { skipInvalid: true });
79
+ const page = await renderPage({ root, file, route: entry ? route : "/404/", site, load, notFound: !entry, pages });
80
+ send(res, entry ? 200 : 404, await server.transformIndexHtml(urlPath, page.html));
81
+ }
82
+ catch (err) {
83
+ const message = err.message;
84
+ server.config.logger.error(message);
85
+ send(res, 500, await server.transformIndexHtml(urlPath, plainPage("Render error", message)));
86
+ }
87
+ });
88
+ await listenOrExplain(() => server.listen(), options.port);
89
+ return server;
90
+ }
91
+ const DISCOVERY_FILES = {
92
+ "/robots.txt": "text/plain; charset=utf-8",
93
+ "/sitemap.xml": "application/xml; charset=utf-8",
94
+ "/llms.txt": "text/markdown; charset=utf-8",
95
+ "/llms-full.txt": "text/markdown; charset=utf-8",
96
+ };
97
+ function send(res, status, html) {
98
+ res.statusCode = status;
99
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
100
+ res.end(html);
101
+ }
102
+ function plainPage(title, message) {
103
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head><body><h1>${escapeHtml(title)}</h1><pre>${escapeHtml(message)}</pre></body></html>`;
104
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Documented robots.txt tokens for AI crawlers, grouped by purpose. The list
3
+ * lives in the rule pack (rule "robots.ai-crawlers") with its official sources;
4
+ * change it only through a reviewed rule-pack release.
5
+ */
6
+ export interface CrawlerToken {
7
+ token: string;
8
+ provider: string;
9
+ purpose: "training" | "search";
10
+ /** Official documentation URL. */
11
+ source: string;
12
+ }
13
+ export declare const AI_CRAWLERS: CrawlerToken[];
@@ -0,0 +1,2 @@
1
+ import { rulePack, ruleData } from "../guidelines/pack.js";
2
+ export const AI_CRAWLERS = ruleData("robots.ai-crawlers").crawlers.map((c) => ({ ...c, source: rulePack.sources.find((s) => s.id === c.source).url }));
@@ -0,0 +1,12 @@
1
+ /**
2
+ * llms.txt (https://llmstxt.org) is an emerging convention. Publishing it does
3
+ * not guarantee that any AI system reads, indexes or cites the site.
4
+ */
5
+ export interface LlmsPage {
6
+ title: string;
7
+ url: string;
8
+ description?: string;
9
+ body: string;
10
+ }
11
+ export declare function llmsTxt(title: string, description: string | undefined, pages: LlmsPage[]): string;
12
+ export declare function llmsFullTxt(title: string, description: string | undefined, pages: LlmsPage[]): string;
@@ -0,0 +1,19 @@
1
+ /** Keeps a value on one line and stops it from forming Markdown links. */
2
+ const inline = (s) => s.replace(/\s+/g, " ").replace(/[[\]()<>]/g, (c) => `\\${c}`).trim();
3
+ export function llmsTxt(title, description, pages) {
4
+ const lines = [`# ${inline(title)}`, ""];
5
+ if (description)
6
+ lines.push(`> ${inline(description)}`, "");
7
+ lines.push("## Pages", "");
8
+ for (const p of pages)
9
+ lines.push(`- [${inline(p.title)}](${p.url.replace(/[()\s<>]/g, encodeURIComponent)})${p.description ? `: ${inline(p.description)}` : ""}`);
10
+ return `${lines.join("\n")}\n`;
11
+ }
12
+ export function llmsFullTxt(title, description, pages) {
13
+ const parts = [`# ${inline(title)}`];
14
+ if (description)
15
+ parts.push(`> ${inline(description)}`);
16
+ for (const p of pages)
17
+ parts.push(`---\n\nSource: ${p.url}\n\n${p.body.trim()}`);
18
+ return `${parts.join("\n\n")}\n`;
19
+ }
@@ -0,0 +1,8 @@
1
+ import type { SiteConfig } from "../core/config.js";
2
+ type RobotsOptions = Exclude<SiteConfig["seo"]["robots"], false>;
3
+ /**
4
+ * robots.txt from explicit config only. Unset AI options add nothing, so the
5
+ * owner's `*` policy applies. robots.txt is advisory and is not access control.
6
+ */
7
+ export declare function robotsTxt(site: SiteConfig, options: RobotsOptions): string;
8
+ export {};