vagejs 0.1.0 → 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 (64) hide show
  1. package/README.md +190 -25
  2. package/dist/checks/check.d.ts +2 -0
  3. package/dist/checks/check.js +106 -2
  4. package/dist/checks/integrity.d.ts +36 -0
  5. package/dist/checks/integrity.js +145 -0
  6. package/dist/checks/links.d.ts +18 -0
  7. package/dist/checks/links.js +47 -0
  8. package/dist/checks/verify.d.ts +35 -0
  9. package/dist/checks/verify.js +167 -0
  10. package/dist/cli/index.js +116 -10
  11. package/dist/cli/info.d.ts +35 -0
  12. package/dist/cli/info.js +49 -4
  13. package/dist/content/images.d.ts +23 -0
  14. package/dist/content/images.js +96 -0
  15. package/dist/content/markdown.d.ts +2 -0
  16. package/dist/content/markdown.js +6 -1
  17. package/dist/content/sanitize.d.ts +11 -2
  18. package/dist/content/sanitize.js +49 -9
  19. package/dist/core/clone.d.ts +6 -0
  20. package/dist/core/clone.js +16 -0
  21. package/dist/core/config.d.ts +9 -0
  22. package/dist/core/config.js +21 -4
  23. package/dist/core/errors.d.ts +2 -0
  24. package/dist/core/errors.js +3 -0
  25. package/dist/core/html.d.ts +22 -0
  26. package/dist/core/html.js +31 -0
  27. package/dist/core/pages.d.ts +40 -0
  28. package/dist/core/pages.js +93 -0
  29. package/dist/core/render.d.ts +17 -1
  30. package/dist/core/render.js +51 -9
  31. package/dist/core/site.d.ts +12 -1
  32. package/dist/core/site.js +36 -13
  33. package/dist/core/validate.d.ts +2 -0
  34. package/dist/core/validate.js +6 -0
  35. package/dist/deploy/cloudflare-pages.js +3 -1
  36. package/dist/deploy/cloudflare-workers.d.ts +59 -0
  37. package/dist/deploy/cloudflare-workers.js +223 -0
  38. package/dist/deploy/headers.js +1 -1
  39. package/dist/deploy/publish.d.ts +1 -1
  40. package/dist/deploy/publish.js +3 -3
  41. package/dist/deploy/redirects.js +1 -1
  42. package/dist/deploy/secrets.js +1 -1
  43. package/dist/dev/server.js +3 -1
  44. package/dist/guidelines/rule-pack.json +1 -1
  45. package/dist/index.d.ts +4 -1
  46. package/dist/index.js +4 -1
  47. package/dist/integrations/api-keys.d.ts +1 -1
  48. package/dist/integrations/api-keys.js +1 -1
  49. package/dist/integrations/google-auth.js +1 -1
  50. package/dist/integrations/tags.d.ts +1 -1
  51. package/dist/integrations/tags.js +1 -1
  52. package/dist/seo/head.js +1 -1
  53. package/package.json +1 -1
  54. package/templates/starter/.github/dependabot.yml +11 -0
  55. package/templates/starter/.github/workflows/ci.yml +2 -0
  56. package/templates/starter/.github/workflows/verify.yml +32 -0
  57. package/templates/starter/AGENTS.md +36 -15
  58. package/templates/starter/links.lock.json +4 -0
  59. package/templates/starter/package.json +1 -1
  60. package/templates/starter/src/layouts/blog.ts +13 -0
  61. package/templates/starter/src/layouts/default.ts +8 -7
  62. package/templates/starter/src/pages/blog/index.md +7 -0
  63. package/templates/starter/src/pages/index.md +2 -2
  64. package/templates/starter/vage.config.ts +5 -1
@@ -1,25 +1,30 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { parseMarkdown } from "../content/markdown.js";
4
- import { sanitizeHtml } from "../content/sanitize.js";
3
+ import { z } from "zod";
4
+ import { frontmatterSchema, parseMarkdown } from "../content/markdown.js";
5
+ import { enhanceTrustedHtml, sanitizeHtml } from "../content/sanitize.js";
5
6
  import { VageError } from "./errors.js";
6
- import { escapeHtml } from "./html.js";
7
+ import { clonePlain } from "./clone.js";
8
+ import { escapeHtml, isSafeHtml } from "./html.js";
7
9
  import { headTags, injectHead, pageMeta } from "../seo/head.js";
8
10
  import { analyticsTags, injectAnalytics } from "../integrations/tags.js";
9
11
  export async function renderPage(options) {
10
12
  const { root, file, route, site, load } = options;
11
13
  const parsed = parseMarkdown(fs.readFileSync(file, "utf8"), file);
12
14
  const { frontmatter, body } = parsed;
13
- const content = site.markdown.html === "allow" ? parsed.html : await sanitizeHtml(parsed.html);
15
+ const publicDir = path.join(root, "public");
16
+ const content = site.markdown.html === "allow" ? await enhanceTrustedHtml(parsed.html, publicDir) : await sanitizeHtml(parsed.html, { publicDir, inlineStyles: site.markdown.inlineStyles });
17
+ const layout = await applyLayoutFields(root, frontmatter, load, file);
14
18
  const meta = pageMeta(site, route, frontmatter, options.notFound);
15
- const layout = await resolveLayout(root, frontmatter.layout, load, file);
16
- const html = await layout({
19
+ const result = await layout({
17
20
  content,
18
21
  page: { route, title: frontmatter.title, description: frontmatter.description, url: meta.canonical, frontmatter },
19
22
  site,
23
+ pages: options.pages ?? [],
20
24
  });
25
+ const html = isSafeHtml(result) ? result.value : result;
21
26
  if (typeof html !== "string") {
22
- throw new VageError(`Layout "${frontmatter.layout ?? "default"}" must return an HTML string.`, file);
27
+ throw new VageError(`Layout "${frontmatter.layout ?? "default"}" must return an HTML string or html\`...\`.`, file);
23
28
  }
24
29
  let out = injectHead(html, headTags(site, meta), file);
25
30
  const tags = options.analytics ? analyticsTags(site) : undefined;
@@ -33,15 +38,52 @@ async function resolveLayout(root, name, load, pageFile) {
33
38
  const file = LAYOUT_EXTENSIONS.map((ext) => base + ext).find((f) => fs.existsSync(f));
34
39
  if (!file) {
35
40
  if (!name)
36
- return defaultLayout;
41
+ return { layout: defaultLayout };
37
42
  throw new VageError(`Layout "${name}" not found. Expected src/layouts/${name}.ts`, pageFile);
38
43
  }
39
- const layout = (await load(file)).default;
44
+ const mod = await load(file);
45
+ const layout = mod.default;
40
46
  if (typeof layout !== "function") {
41
47
  throw new VageError("Layout files must default-export a function that returns an HTML string.", file);
42
48
  }
49
+ const schema = mod.frontmatter;
50
+ if (schema !== undefined && (typeof schema?.safeParse !== "function" || typeof schema.shape !== "object")) {
51
+ throw new VageError('`export const frontmatter` in a layout must be a Zod object schema: z.object({ ... }) (import { z } from "vagejs").', file);
52
+ }
53
+ return { layout: layout, schema };
54
+ }
55
+ const BUILT_IN_FIELDS = new Set(Object.keys(frontmatterSchema.shape));
56
+ /**
57
+ * Loads the page's layout and, if it exports a `frontmatter` schema, validates the page
58
+ * against it and fills in defaults (mutating `frontmatter`). Used for rendering and for
59
+ * `props.pages`, so both see the same values. Returns the layout function.
60
+ */
61
+ export async function applyLayoutFields(root, frontmatter, load, file) {
62
+ const { layout, schema } = await resolveLayout(root, frontmatter.layout, load, file);
63
+ if (schema)
64
+ Object.assign(frontmatter, validateLayoutFields(frontmatter, schema, frontmatter.layout ?? "default", file));
43
65
  return layout;
44
66
  }
67
+ /**
68
+ * Checks the fields a page adds for its layout against the layout's schema. Unknown
69
+ * fields are errors (a typo would otherwise do nothing); defaults are filled in. A
70
+ * schema may also declare built-in fields (e.g. make `author` required); those are
71
+ * validated too. Built-in defaults (type "website", draft/noindex false) are applied
72
+ * first, so a layout can restrict them but not change their defaults.
73
+ */
74
+ function validateLayoutFields(frontmatter, schema, layoutName, file) {
75
+ const declared = new Set(Object.keys(schema.shape ?? {}));
76
+ const own = Object.fromEntries(Object.entries(frontmatter).filter(([k]) => !BUILT_IN_FIELDS.has(k) || declared.has(k)));
77
+ // Strict unless the layout chose otherwise (.passthrough() / z.looseObject set a catchall).
78
+ const loose = schema._zod?.def?.catchall !== undefined;
79
+ const result = (loose || !schema.strict ? schema : schema.strict()).safeParse(own);
80
+ if (!result.success) {
81
+ const detail = result.error instanceof z.ZodError ? z.prettifyError(result.error) : String(result.error);
82
+ throw new VageError(`Frontmatter doesn't match the "${layoutName}" layout's fields:\n${detail}`, file);
83
+ }
84
+ // Zod reuses a default's nested objects across parses; give each page its own copy.
85
+ return clonePlain(result.data);
86
+ }
45
87
  /** Used when a project has no src/layouts/default.ts. */
46
88
  export const defaultLayout = ({ content, page, site }) => {
47
89
  const title = site.title && site.title !== page.title ? `${page.title} | ${site.title}` : page.title;
@@ -12,8 +12,17 @@ export interface SiteRedirect {
12
12
  outFile: string;
13
13
  html: string;
14
14
  }
15
+ export interface PageError {
16
+ /** Source path relative to the project root. */
17
+ source: string;
18
+ /** The route the page would have had (links to it aren't reported again as broken). */
19
+ route?: string;
20
+ message: string;
21
+ }
15
22
  export interface SiteModel {
16
23
  site: SiteConfig;
24
+ /** Pages that failed to render (only with `collectErrors`; otherwise the first failure throws). */
25
+ pageErrors: PageError[];
17
26
  /** Published pages (drafts excluded). */
18
27
  pages: SitePage[];
19
28
  skippedDrafts: string[];
@@ -26,6 +35,8 @@ export interface SiteModel {
26
35
  warnings: string[];
27
36
  }
28
37
  /** Renders every page and derives discovery files from the same data, without writing anything. */
29
- export declare function renderSite(root: string, load: ModuleLoader): Promise<SiteModel>;
38
+ export declare function renderSite(root: string, load: ModuleLoader, options?: {
39
+ collectErrors?: boolean;
40
+ }): Promise<SiteModel>;
30
41
  /** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
31
42
  export declare function listedPages(model: SiteModel): SitePage[];
package/dist/core/site.js CHANGED
@@ -12,12 +12,14 @@ import { loadConfig } from "./config.js";
12
12
  import { VageError } from "./errors.js";
13
13
  import { escapeHtml } from "./html.js";
14
14
  import { renderPage } from "./render.js";
15
+ import { parseFrontmatter } from "../content/markdown.js";
16
+ import { collectPages } from "./pages.js";
15
17
  import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "./routes.js";
16
18
  /** Renders every page and derives discovery files from the same data, without writing anything. */
17
- export async function renderSite(root, load) {
19
+ export async function renderSite(root, load, options = {}) {
18
20
  const pagesDir = path.join(root, "src", "pages");
19
21
  if (!fs.existsSync(pagesDir)) {
20
- throw new VageError(`No src/pages directory found in ${root}. Is this a VAGE JS project?`);
22
+ throw new VageError(`No src/pages directory found in ${root}. Is this a vagejs project?`);
21
23
  }
22
24
  const site = await loadConfig(root, load);
23
25
  const pinned = site.guidelines.rulePack;
@@ -25,21 +27,40 @@ export async function renderSite(root, load) {
25
27
  throw new VageError(`This project pins rule pack ${pinned}, but the installed vagejs ships ${rulePack.version}. ` +
26
28
  `Review what changed with "vage guidelines", then update guidelines.rulePack in vage.config.ts.`);
27
29
  }
28
- const model = { site, pages: [], skippedDrafts: [], draftRoutes: [], redirects: [], files: new Map(), warnings: [] };
30
+ const model = { site, pageErrors: [], pages: [], skippedDrafts: [], draftRoutes: [], redirects: [], files: new Map(), warnings: [] };
31
+ const collect = options.collectErrors === true;
32
+ const attempt = async (file, route, work) => {
33
+ try {
34
+ await work();
35
+ }
36
+ catch (err) {
37
+ if (!collect)
38
+ throw err;
39
+ // Layout code can throw anything (or fail to compile); report it as this page's error.
40
+ const message = err instanceof VageError ? err.detail : `Layout or render error: ${err?.message ?? String(err)}`;
41
+ model.pageErrors.push({ source: path.relative(root, file), route, message });
42
+ }
43
+ };
44
+ const pages = await collectPages(root, site, load, { skipInvalid: collect });
29
45
  for (const entry of discoverRoutes(pagesDir)) {
30
- const page = await renderPage({ root, file: entry.file, route: entry.route, site, load, analytics: true });
31
46
  const source = path.relative(root, entry.file);
32
- if (page.frontmatter.draft) {
33
- model.skippedDrafts.push(source);
34
- model.draftRoutes.push(entry.route);
35
- }
36
- else
47
+ await attempt(entry.file, entry.route, async () => {
48
+ // Drafts are skipped before rendering, so their fields can't fail a build.
49
+ if (parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file).frontmatter.draft) {
50
+ model.skippedDrafts.push(source);
51
+ model.draftRoutes.push(entry.route);
52
+ return;
53
+ }
54
+ const page = await renderPage({ root, file: entry.file, route: entry.route, site, load, analytics: true, pages });
37
55
  model.pages.push({ ...page, route: entry.route, source, outFile: entry.outFile });
56
+ });
38
57
  }
39
58
  const notFound = findNotFoundPage(pagesDir);
40
59
  if (notFound) {
41
- const page = await renderPage({ root, file: notFound, route: "/404/", site, load, notFound: true, analytics: true });
42
- model.notFound = { ...page, route: "/404/", source: path.relative(root, notFound), outFile: "404.html" };
60
+ await attempt(notFound, undefined, async () => {
61
+ const page = await renderPage({ root, file: notFound, route: "/404/", site, load, notFound: true, analytics: true, pages });
62
+ model.notFound = { ...page, route: "/404/", source: path.relative(root, notFound), outFile: "404.html" };
63
+ });
43
64
  }
44
65
  checkPublicDir(root, model.warnings);
45
66
  model.redirects = buildRedirects(site, new Set(model.pages.map((p) => p.route)));
@@ -78,9 +99,11 @@ function addDiscoveryFiles(root, model) {
78
99
  const indexNow = site.integrations.indexNow;
79
100
  if (indexNow)
80
101
  generate(`${indexNow.key}.txt`, () => `${indexNow.key}\n`);
81
- if (site.deploy.target === "cloudflare-pages" && model.redirects.length)
102
+ // Cloudflare Pages and Workers static assets both read _redirects and _headers.
103
+ const cloudflare = site.deploy.target === "cloudflare-pages" || site.deploy.target === "cloudflare-workers";
104
+ if (cloudflare && model.redirects.length)
82
105
  generate("_redirects", () => cloudflareRedirects(model.redirects));
83
- if (site.deploy.target === "cloudflare-pages" && site.security.headers) {
106
+ if (cloudflare && site.security.headers) {
84
107
  const html = [...model.pages, ...(model.notFound ? [model.notFound] : []), ...model.redirects].map((p) => p.html);
85
108
  generate("_headers", () => headersFile(html, site));
86
109
  }
@@ -4,6 +4,8 @@
4
4
  */
5
5
  export declare function assertBranchName(value: string, flag?: string): string;
6
6
  export declare function assertCloudflareProject(value: string): string;
7
+ /** Cloudflare Worker names: lowercase letters, digits and "-", up to 63 characters. */
8
+ export declare function assertWorkerName(value: string): string;
7
9
  /** https://, ssh://, git@host:path, or a local path. Refuses options and git's command-running transports. */
8
10
  export declare function assertGitRemote(value: string): string;
9
11
  /** npm dependency spec for new projects: a version/range, "latest", or a local file: tarball. */
@@ -14,6 +14,12 @@ export function assertCloudflareProject(value) {
14
14
  throw new VageError(`Invalid --project "${value}". Use lowercase letters, digits and "-".`);
15
15
  return value;
16
16
  }
17
+ /** Cloudflare Worker names: lowercase letters, digits and "-", up to 63 characters. */
18
+ export function assertWorkerName(value) {
19
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(value))
20
+ throw new VageError(`Invalid Worker name "${value}". Use lowercase letters, digits and "-" (up to 63).`);
21
+ return value;
22
+ }
17
23
  /** https://, ssh://, git@host:path, or a local path. Refuses options and git's command-running transports. */
18
24
  export function assertGitRemote(value) {
19
25
  if (value.startsWith("-") || /^(ext|fd)::/i.test(value) || /[\s\u0000-\u001f]/.test(value)) {
@@ -20,7 +20,9 @@ export async function deployCloudflarePages(root, options) {
20
20
  if (res.code !== 0) {
21
21
  const auth = /auth|login|token|unauthori[sz]ed|10000/i.test(output)
22
22
  ? " Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, or run `npx wrangler login`."
23
- : "";
23
+ : /pages_build_output_dir|project not found|delegat|Workers/i.test(output)
24
+ ? ' Cloudflare now creates new sites as Workers rather than Pages projects. Set deploy.target to "cloudflare-workers" in vage.config.ts and run vage deploy again.'
25
+ : "";
24
26
  return { status: "failure", branch: options.branch, reason: `wrangler failed (exit ${res.code}).${auth}\n${tail(output)}` };
25
27
  }
26
28
  const deploymentUrl = output.match(/https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.pages\.dev/gi)?.at(-1);
@@ -0,0 +1,59 @@
1
+ import type { DeployStatus, Runner } from "./github-pages.js";
2
+ /** In wrangler's own lookup order. */
3
+ export declare const WRANGLER_CONFIGS: string[];
4
+ /** Compatibility date for the generated config; Workers static assets need nothing newer. Checked 2026-10-23. */
5
+ export declare const COMPATIBILITY_DATE = "2026-10-01";
6
+ /** What vagejs needs to know from a project's own wrangler config. */
7
+ export interface WranglerConfig {
8
+ file: string;
9
+ name?: string;
10
+ /** assets.directory, resolved against the project root. */
11
+ assetsDir?: string;
12
+ notFoundHandling?: string;
13
+ }
14
+ /**
15
+ * Reads name and assets settings from the project's wrangler config. Refuses setups where
16
+ * wrangler could pick a different file or Worker than the one shown: several config files,
17
+ * a CLOUDFLARE_ENV environment, or a .wrangler/deploy redirect.
18
+ */
19
+ export declare function readWranglerConfig(root: string, env?: Record<string, string | undefined>): WranglerConfig | undefined;
20
+ export interface WorkersPlan {
21
+ /** The Worker that will actually be deployed. */
22
+ worker: string;
23
+ /** The project's wrangler config, if it has one. */
24
+ config?: WranglerConfig;
25
+ warnings: string[];
26
+ }
27
+ /**
28
+ * Decides which Worker gets deployed, and refuses setups where the plan and what
29
+ * wrangler would actually do differ (another Worker name, or a folder other than dist/).
30
+ */
31
+ export declare function planWorkersDeploy(root: string, requested: string | undefined): WorkersPlan;
32
+ export interface WorkersDeployment extends DeployStatus {
33
+ worker: string;
34
+ /** How it was deployed: a new version switched to 100% (existing Worker), or a full first deploy. */
35
+ mode?: "version" | "first-deploy";
36
+ versionId?: string;
37
+ }
38
+ /**
39
+ * Deploys dist/ as a Cloudflare Worker with static assets (what Cloudflare now creates
40
+ * instead of Pages projects; `_headers` and `_redirects` still apply).
41
+ *
42
+ * - With a wrangler config in the project (needed for custom domains and routes), that
43
+ * file is used as-is; `planWorkersDeploy` has already checked its name and folder.
44
+ * Otherwise a temporary config pointing at dist/ is generated, so the project gains no files.
45
+ * - Existing Workers get `wrangler versions upload` + `versions deploy <id>@100%`: the new
46
+ * version takes all traffic without re-applying routes or custom domains, so the site
47
+ * stays up. Only Cloudflare's "Worker doesn't exist" error leads to a first `wrangler deploy`.
48
+ * - Success needs wrangler to succeed AND the live site to answer 200: `siteUrl` when set,
49
+ * else the Worker's workers.dev URL (never a version preview URL).
50
+ */
51
+ export declare function deployCloudflareWorkers(root: string, options: {
52
+ plan: WorkersPlan;
53
+ tag?: string;
54
+ siteUrl?: string;
55
+ run?: Runner;
56
+ fetch?: typeof fetch;
57
+ sleep?: (ms: number) => Promise<void>;
58
+ env?: Record<string, string | undefined>;
59
+ }): Promise<WorkersDeployment>;
@@ -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
+ }
@@ -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"],
@@ -6,6 +6,7 @@ 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 { collectPages } from "../core/pages.js";
9
10
  import { renderSite } from "../core/site.js";
10
11
  import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "../core/routes.js";
11
12
  import { devAccessGuard } from "./guard.js";
@@ -74,7 +75,8 @@ export async function startDevServer(root, options = {}) {
74
75
  return send(res, 404, await server.transformIndexHtml(urlPath, plainPage("404 — Not found", `No page for ${route}`)));
75
76
  }
76
77
  const file = entry?.file ?? notFound;
77
- const page = await renderPage({ root, file, route: entry ? route : "/404/", site, load, notFound: !entry });
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 });
78
80
  send(res, entry ? 200 : 404, await server.transformIndexHtml(urlPath, page.html));
79
81
  }
80
82
  catch (err) {
@@ -604,7 +604,7 @@
604
604
  "version": "2026.10.0",
605
605
  "date": "2026-10-23",
606
606
  "summary": "Initial rule pack (unreleased; extended during Phase 8 and the security review with IndexNow, Clarity, Plausible, Cloudflare Pages and CSP provider rules). Facts checked against the cited sources on 2026-10-23 during implementation.",
607
- "reviewedBy": "A G E Venkatesh",
607
+ "reviewedBy": "Venkatesh A G E",
608
608
  "contentHash": "8c7272511f733c01984ce1d962b39e2d521a88b31a5c958a95307a04b82bb3dd"
609
609
  }
610
610
  ]
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export { defineConfig, type SiteConfig, type UserConfig } from "./core/config.js";
2
- export { escapeHtml } from "./core/html.js";
2
+ export { escapeHtml, html, raw, type SafeHtml } from "./core/html.js";
3
+ /** Zod, for layout field schemas: `export const frontmatter = z.object({ ... })`. Same instance vagejs validates with. */
4
+ export { z } from "zod";
3
5
  export type { Layout, LayoutProps } from "./core/render.js";
4
6
  export type { Frontmatter } from "./content/markdown.js";
7
+ export { collection, type CollectionOptions, type PageSummary } from "./core/pages.js";
5
8
  export { build, type BuildResult } from "./core/build.js";
6
9
  export { startDevServer } from "./dev/server.js";
7
10
  export { startPreviewServer } from "./dev/preview.js";
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  export { defineConfig } from "./core/config.js";
2
- export { escapeHtml } from "./core/html.js";
2
+ export { escapeHtml, html, raw } from "./core/html.js";
3
+ /** Zod, for layout field schemas: `export const frontmatter = z.object({ ... })`. Same instance vagejs validates with. */
4
+ export { z } from "zod";
5
+ export { collection } from "./core/pages.js";
3
6
  export { build } from "./core/build.js";
4
7
  export { startDevServer } from "./dev/server.js";
5
8
  export { startPreviewServer } from "./dev/preview.js";