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,168 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parseFrontmatter } from "../content/markdown.js";
4
+ import { pageMeta } from "../seo/head.js";
5
+ import { clonePlain } from "./clone.js";
6
+ import { instant } from "./dates.js";
7
+ import { applyLayoutFields } from "./render.js";
8
+ import { VageError } from "./errors.js";
9
+ import { discoverRoutes } from "./routes.js";
10
+ /**
11
+ * Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
12
+ * and applies each page's layout schema, so list entries get the same defaults as the
13
+ * page itself. The result is frozen: layouts share it, so none may change it.
14
+ * `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
15
+ * they report their own error instead of breaking every page that lists others.
16
+ */
17
+ export async function collectPages(root, site, load, options = {}) {
18
+ const pagesDir = path.join(root, "src", "pages");
19
+ const out = [];
20
+ for (const entry of discoverRoutes(pagesDir)) {
21
+ let frontmatter;
22
+ try {
23
+ ({ frontmatter } = parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file));
24
+ if (frontmatter.draft)
25
+ continue;
26
+ await applyLayoutFields(root, frontmatter, load, entry.file);
27
+ }
28
+ catch (err) {
29
+ // Any failure (bad frontmatter, a layout that throws or doesn't compile) is that page's own error.
30
+ if (options.skipInvalid)
31
+ continue;
32
+ throw err;
33
+ }
34
+ out.push({
35
+ route: entry.route,
36
+ url: pageMeta(site, entry.route, frontmatter).canonical,
37
+ title: frontmatter.title,
38
+ label: frontmatter.navTitle ?? frontmatter.title,
39
+ description: frontmatter.description,
40
+ source: path.relative(root, entry.file),
41
+ frontmatter,
42
+ });
43
+ }
44
+ // A frozen deep copy: shared by every layout, and never the objects a page renders with.
45
+ return deepFreeze(clonePlain(out));
46
+ }
47
+ /** Freezes plain data only; URL/Date/class instances from schema transforms stay usable. */
48
+ function deepFreeze(value) {
49
+ const plain = Array.isArray(value) || (value && typeof value === "object" && [Object.prototype, null].includes(Object.getPrototypeOf(value)));
50
+ if (plain && !Object.isFrozen(value)) {
51
+ Object.freeze(value);
52
+ for (const v of Object.values(value))
53
+ deepFreeze(v);
54
+ }
55
+ return value;
56
+ }
57
+ const SORTS = ["date", "title", "route", "order"];
58
+ /**
59
+ * Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
60
+ * collection(pages, { under: "/blog/", sort: "date", limit: 5 })
61
+ */
62
+ /** By `order` (lowest first; pages without one last), then title. */
63
+ export function byOrder(a, b) {
64
+ const oa = a.frontmatter.order;
65
+ const ob = b.frontmatter.order;
66
+ if (oa !== ob)
67
+ return oa === undefined ? 1 : ob === undefined ? -1 : oa - ob;
68
+ return a.title.localeCompare(b.title);
69
+ }
70
+ export function collection(pages, options = {}) {
71
+ if (!Array.isArray(pages))
72
+ throw new VageError("collection(): pass the layout's `pages` as the first argument.");
73
+ const { under, includeIndex = false, sort = "route", limit } = options;
74
+ if (under !== undefined && !/^\/(?!\/)/.test(under)) {
75
+ throw new VageError(`collection(): "under" must be a site path starting with "/", like "/blog/" (got ${JSON.stringify(under)}).`);
76
+ }
77
+ if (typeof sort !== "function" && !SORTS.includes(sort)) {
78
+ throw new VageError(`collection(): "sort" must be "date", "order", "title", "route" or a compare function (got ${JSON.stringify(sort)}).`);
79
+ }
80
+ if (typeof includeIndex !== "boolean")
81
+ throw new VageError(`collection(): "includeIndex" must be true or false (got ${JSON.stringify(includeIndex)}).`);
82
+ if (limit !== undefined && !(Number.isInteger(limit) && limit >= 0)) {
83
+ throw new VageError(`collection(): "limit" must be a whole number of 0 or more (got ${JSON.stringify(limit)}).`);
84
+ }
85
+ const prefix = under ? (under.endsWith("/") ? under : `${under}/`) : undefined;
86
+ const picked = pages.filter((p) => !prefix || (p.route.startsWith(prefix) && (includeIndex || p.route !== prefix)));
87
+ const byTitle = (a, b) => a.title.localeCompare(b.title);
88
+ const compare = typeof sort === "function"
89
+ ? sort
90
+ : sort === "title"
91
+ ? byTitle
92
+ : sort === "order"
93
+ ? byOrder
94
+ : sort === "date"
95
+ ? (a, b) => {
96
+ const ta = instant(a.frontmatter.datePublished);
97
+ const tb = instant(b.frontmatter.datePublished);
98
+ if (ta !== tb && !(Number.isNaN(ta) && Number.isNaN(tb)))
99
+ return Number.isNaN(ta) ? 1 : Number.isNaN(tb) ? -1 : tb - ta;
100
+ return byTitle(a, b);
101
+ }
102
+ : (a, b) => a.route.localeCompare(b.route);
103
+ const sorted = [...picked].sort(compare);
104
+ return limit === undefined ? sorted : sorted.slice(0, limit);
105
+ }
106
+ /** The pages before and after `route` in an ordered list (e.g. from collection()), for "Previous / Next" links. */
107
+ export function neighbors(list, route) {
108
+ const i = list.findIndex((p) => p.route === route);
109
+ if (i === -1)
110
+ return {};
111
+ return { prev: list[i - 1], next: list[i + 1] };
112
+ }
113
+ /**
114
+ * The trail from the home page to `route`, using the pages that exist at each level:
115
+ * /docs/guides/setup/ -> [Home, Docs, Guides, Setup] (missing levels are skipped).
116
+ */
117
+ export function breadcrumbs(pages, route) {
118
+ const byRoute = new Map(pages.map((p) => [p.route, p]));
119
+ const parts = route.split("/").filter(Boolean);
120
+ const trail = [];
121
+ for (let i = 0; i <= parts.length; i++) {
122
+ const r = i === 0 ? "/" : `/${parts.slice(0, i).join("/")}/`;
123
+ const page = byRoute.get(r);
124
+ if (page)
125
+ trail.push(page);
126
+ }
127
+ return trail;
128
+ }
129
+ /**
130
+ * Pages under `under` as a nested tree (folders become levels), each level sorted by
131
+ * `order`, then title: ready for a sidebar. A folder without its own index page is
132
+ * skipped; its pages move up a level.
133
+ */
134
+ export function tree(pages, under = "/") {
135
+ if (!/^\/(?!\/)/.test(under))
136
+ throw new VageError(`tree(): "under" must be a site path starting with "/" (got ${JSON.stringify(under)}).`);
137
+ const prefix = under.endsWith("/") ? under : `${under}/`;
138
+ const inside = pages.filter((p) => p.route.startsWith(prefix) && p.route !== prefix);
139
+ const routes = new Set(inside.map((p) => p.route));
140
+ const parentOf = (route) => {
141
+ const parts = route.split("/").filter(Boolean);
142
+ for (let i = parts.length - 1; i > 0; i--) {
143
+ const r = `/${parts.slice(0, i).join("/")}/`;
144
+ if (r.length <= prefix.length)
145
+ return undefined;
146
+ if (routes.has(r))
147
+ return r;
148
+ }
149
+ return undefined;
150
+ };
151
+ const nodes = new Map(inside.map((p) => [p.route, { page: p, children: [] }]));
152
+ const roots = [];
153
+ for (const node of nodes.values()) {
154
+ const parent = parentOf(node.page.route);
155
+ (parent ? nodes.get(parent).children : roots).push(node);
156
+ }
157
+ const sort = (list) => {
158
+ list.sort((a, b) => byOrder(a.page, b.page));
159
+ for (const n of list)
160
+ sort(n.children);
161
+ return list;
162
+ };
163
+ return sort(roots);
164
+ }
165
+ /** A tree() in sidebar reading order (each page, then its children), for neighbors() in nested docs. */
166
+ export function flatten(nodes) {
167
+ return nodes.flatMap((n) => [n.page, ...flatten(n.children)]);
168
+ }
@@ -1,6 +1,9 @@
1
1
  import { type Frontmatter } from "../content/markdown.js";
2
+ import { type Resize } from "../content/optimize.js";
2
3
  import type { ModuleLoader, SiteConfig } from "./config.js";
4
+ import { type SafeHtml } from "./html.js";
3
5
  import { type PageMeta } from "../seo/head.js";
6
+ import type { PageSummary } from "./pages.js";
4
7
  export interface LayoutProps {
5
8
  /** Rendered page body HTML. Insert as-is; do not escape. */
6
9
  content: string;
@@ -13,10 +16,20 @@ export interface LayoutProps {
13
16
  frontmatter: Frontmatter;
14
17
  };
15
18
  site: SiteConfig;
19
+ /**
20
+ * Every published page (drafts excluded), sorted by route. Use `collection()` to
21
+ * pick a section and order it, e.g. a blog index or a sidebar.
22
+ */
23
+ pages: readonly PageSummary[];
16
24
  }
17
- export type Layout = (props: LayoutProps) => string | Promise<string>;
25
+ /** Returns the page's HTML: a string, or `html\`...\`` (escapes values automatically). */
26
+ export type Layout = (props: LayoutProps) => string | SafeHtml | Promise<string | SafeHtml>;
18
27
  export interface RenderedPage {
19
28
  html: string;
29
+ /** The page body as rendered and sanitized, before the layout (source of Markdown copies). */
30
+ content: string;
31
+ /** Generated image files this page uses: published path -> file on disk (images.optimize). */
32
+ assets: Map<string, string>;
20
33
  frontmatter: Frontmatter;
21
34
  /** Markdown body without frontmatter. */
22
35
  body: string;
@@ -32,6 +45,16 @@ export declare function renderPage(options: {
32
45
  notFound?: boolean;
33
46
  /** Add configured analytics tags. Off in the dev server so development traffic is never measured. */
34
47
  analytics?: boolean;
48
+ /** Published pages for `props.pages`. */
49
+ pages?: readonly PageSummary[];
50
+ /** Resizer for images.optimize (tests inject one; sharp otherwise). */
51
+ resize?: Resize;
35
52
  }): Promise<RenderedPage>;
53
+ /**
54
+ * Loads the page's layout and, if it exports a `frontmatter` schema, validates the page
55
+ * against it and fills in defaults (mutating `frontmatter`). Used for rendering and for
56
+ * `props.pages`, so both see the same values. Returns the layout function.
57
+ */
58
+ export declare function applyLayoutFields(root: string, frontmatter: Frontmatter, load: ModuleLoader, file: string): Promise<Layout>;
36
59
  /** Used when a project has no src/layouts/default.ts. */
37
60
  export declare const defaultLayout: Layout;
@@ -1,31 +1,53 @@
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 { contentImageSources, imageVariants, sharpResize } from "../content/optimize.js";
6
+ import { enhanceTrustedHtml, sanitizeHtml } from "../content/sanitize.js";
5
7
  import { VageError } from "./errors.js";
6
- import { escapeHtml } from "./html.js";
8
+ import { clonePlain } from "./clone.js";
9
+ import { escapeHtml, isSafeHtml } from "./html.js";
7
10
  import { headTags, injectHead, pageMeta } from "../seo/head.js";
8
11
  import { analyticsTags, injectAnalytics } from "../integrations/tags.js";
9
12
  export async function renderPage(options) {
10
13
  const { root, file, route, site, load } = options;
11
14
  const parsed = parseMarkdown(fs.readFileSync(file, "utf8"), file);
12
15
  const { frontmatter, body } = parsed;
13
- const content = site.markdown.html === "allow" ? parsed.html : await sanitizeHtml(parsed.html);
16
+ const publicDir = path.join(root, "public");
17
+ const assets = new Map();
18
+ let variants;
19
+ if (site.images.optimize) {
20
+ const resize = options.resize ?? (await sharpResize(root));
21
+ variants = new Map();
22
+ for (const src of contentImageSources(parsed.html)) {
23
+ const v = await imageVariants(src, root, site.images, resize);
24
+ if (!v)
25
+ continue;
26
+ variants.set(src, { srcset: v.srcset, sizes: site.images.sizes });
27
+ for (const [k, f] of v.files)
28
+ assets.set(k, f);
29
+ }
30
+ }
31
+ const content = site.markdown.html === "allow"
32
+ ? await enhanceTrustedHtml(parsed.html, publicDir, variants)
33
+ : await sanitizeHtml(parsed.html, { publicDir, inlineStyles: site.markdown.inlineStyles, variants });
34
+ const layout = await applyLayoutFields(root, frontmatter, load, file);
14
35
  const meta = pageMeta(site, route, frontmatter, options.notFound);
15
- const layout = await resolveLayout(root, frontmatter.layout, load, file);
16
- const html = await layout({
36
+ const result = await layout({
17
37
  content,
18
38
  page: { route, title: frontmatter.title, description: frontmatter.description, url: meta.canonical, frontmatter },
19
39
  site,
40
+ pages: options.pages ?? [],
20
41
  });
42
+ const html = isSafeHtml(result) ? result.value : result;
21
43
  if (typeof html !== "string") {
22
- throw new VageError(`Layout "${frontmatter.layout ?? "default"}" must return an HTML string.`, file);
44
+ throw new VageError(`Layout "${frontmatter.layout ?? "default"}" must return an HTML string or html\`...\`.`, file);
23
45
  }
24
46
  let out = injectHead(html, headTags(site, meta), file);
25
47
  const tags = options.analytics ? analyticsTags(site) : undefined;
26
48
  if (tags)
27
49
  out = injectAnalytics(out, tags, file);
28
- return { html: out, frontmatter, body, meta };
50
+ return { html: out, content, frontmatter, body, meta, assets };
29
51
  }
30
52
  const LAYOUT_EXTENSIONS = [".ts", ".js", ".mjs"];
31
53
  async function resolveLayout(root, name, load, pageFile) {
@@ -33,15 +55,52 @@ async function resolveLayout(root, name, load, pageFile) {
33
55
  const file = LAYOUT_EXTENSIONS.map((ext) => base + ext).find((f) => fs.existsSync(f));
34
56
  if (!file) {
35
57
  if (!name)
36
- return defaultLayout;
58
+ return { layout: defaultLayout };
37
59
  throw new VageError(`Layout "${name}" not found. Expected src/layouts/${name}.ts`, pageFile);
38
60
  }
39
- const layout = (await load(file)).default;
61
+ const mod = await load(file);
62
+ const layout = mod.default;
40
63
  if (typeof layout !== "function") {
41
64
  throw new VageError("Layout files must default-export a function that returns an HTML string.", file);
42
65
  }
66
+ const schema = mod.frontmatter;
67
+ if (schema !== undefined && (typeof schema?.safeParse !== "function" || typeof schema.shape !== "object")) {
68
+ throw new VageError('`export const frontmatter` in a layout must be a Zod object schema: z.object({ ... }) (import { z } from "vagejs").', file);
69
+ }
70
+ return { layout: layout, schema };
71
+ }
72
+ const BUILT_IN_FIELDS = new Set(Object.keys(frontmatterSchema.shape));
73
+ /**
74
+ * Loads the page's layout and, if it exports a `frontmatter` schema, validates the page
75
+ * against it and fills in defaults (mutating `frontmatter`). Used for rendering and for
76
+ * `props.pages`, so both see the same values. Returns the layout function.
77
+ */
78
+ export async function applyLayoutFields(root, frontmatter, load, file) {
79
+ const { layout, schema } = await resolveLayout(root, frontmatter.layout, load, file);
80
+ if (schema)
81
+ Object.assign(frontmatter, validateLayoutFields(frontmatter, schema, frontmatter.layout ?? "default", file));
43
82
  return layout;
44
83
  }
84
+ /**
85
+ * Checks the fields a page adds for its layout against the layout's schema. Unknown
86
+ * fields are errors (a typo would otherwise do nothing); defaults are filled in. A
87
+ * schema may also declare built-in fields (e.g. make `author` required); those are
88
+ * validated too. Built-in defaults (type "website", draft/noindex false) are applied
89
+ * first, so a layout can restrict them but not change their defaults.
90
+ */
91
+ function validateLayoutFields(frontmatter, schema, layoutName, file) {
92
+ const declared = new Set(Object.keys(schema.shape ?? {}));
93
+ const own = Object.fromEntries(Object.entries(frontmatter).filter(([k]) => !BUILT_IN_FIELDS.has(k) || declared.has(k)));
94
+ // Strict unless the layout chose otherwise (.passthrough() / z.looseObject set a catchall).
95
+ const loose = schema._zod?.def?.catchall !== undefined;
96
+ const result = (loose || !schema.strict ? schema : schema.strict()).safeParse(own);
97
+ if (!result.success) {
98
+ const detail = result.error instanceof z.ZodError ? z.prettifyError(result.error) : String(result.error);
99
+ throw new VageError(`Frontmatter doesn't match the "${layoutName}" layout's fields:\n${detail}`, file);
100
+ }
101
+ // Zod reuses a default's nested objects across parses; give each page its own copy.
102
+ return clonePlain(result.data);
103
+ }
45
104
  /** Used when a project has no src/layouts/default.ts. */
46
105
  export const defaultLayout = ({ content, page, site }) => {
47
106
  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
@@ -1,6 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { llmsFullTxt, llmsTxt } from "../discovery/llms.js";
4
+ import { feedPath, rssFeed } from "../discovery/feed.js";
5
+ import { gitPageDates } from "../discovery/git-dates.js";
6
+ import { htmlToMarkdown, markdownCopy, markdownCopyPath } from "../discovery/markdown-copy.js";
4
7
  import { robotsTxt } from "../discovery/robots.js";
5
8
  import { SITEMAP_URL_LIMIT, sitemapXml } from "../discovery/sitemap.js";
6
9
  import { headersFile } from "../deploy/headers.js";
@@ -12,12 +15,15 @@ import { loadConfig } from "./config.js";
12
15
  import { VageError } from "./errors.js";
13
16
  import { escapeHtml } from "./html.js";
14
17
  import { renderPage } from "./render.js";
18
+ import { parseFrontmatter } from "../content/markdown.js";
19
+ import { sharpResize } from "../content/optimize.js";
20
+ import { collectPages } from "./pages.js";
15
21
  import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "./routes.js";
16
22
  /** Renders every page and derives discovery files from the same data, without writing anything. */
17
- export async function renderSite(root, load) {
23
+ export async function renderSite(root, load, options = {}) {
18
24
  const pagesDir = path.join(root, "src", "pages");
19
25
  if (!fs.existsSync(pagesDir)) {
20
- throw new VageError(`No src/pages directory found in ${root}. Is this a VAGE JS project?`);
26
+ throw new VageError(`No src/pages directory found in ${root}. Is this a vagejs project?`);
21
27
  }
22
28
  const site = await loadConfig(root, load);
23
29
  const pinned = site.guidelines.rulePack;
@@ -25,35 +31,64 @@ export async function renderSite(root, load) {
25
31
  throw new VageError(`This project pins rule pack ${pinned}, but the installed vagejs ships ${rulePack.version}. ` +
26
32
  `Review what changed with "vage guidelines", then update guidelines.rulePack in vage.config.ts.`);
27
33
  }
28
- const model = { site, pages: [], skippedDrafts: [], draftRoutes: [], redirects: [], files: new Map(), warnings: [] };
34
+ const model = { site, pageErrors: [], pages: [], skippedDrafts: [], draftRoutes: [], redirects: [], files: new Map(), warnings: [] };
35
+ const collect = options.collectErrors === true;
36
+ const attempt = async (file, route, work) => {
37
+ try {
38
+ await work();
39
+ }
40
+ catch (err) {
41
+ if (!collect)
42
+ throw err;
43
+ // Layout code can throw anything (or fail to compile); report it as this page's error.
44
+ const message = err instanceof VageError ? err.detail : `Layout or render error: ${err?.message ?? String(err)}`;
45
+ model.pageErrors.push({ source: path.relative(root, file), route, message });
46
+ }
47
+ };
48
+ const pages = await collectPages(root, site, load, { skipInvalid: collect });
49
+ // Loaded once for the whole site, so a missing sharp is one clear error, not one per page.
50
+ const resize = site.images.optimize ? await sharpResize(root) : undefined;
29
51
  for (const entry of discoverRoutes(pagesDir)) {
30
- const page = await renderPage({ root, file: entry.file, route: entry.route, site, load, analytics: true });
31
52
  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
53
+ await attempt(entry.file, entry.route, async () => {
54
+ // Drafts are skipped before rendering, so their fields can't fail a build.
55
+ if (parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file).frontmatter.draft) {
56
+ model.skippedDrafts.push(source);
57
+ model.draftRoutes.push(entry.route);
58
+ return;
59
+ }
60
+ const page = await renderPage({ root, file: entry.file, route: entry.route, site, load, analytics: true, pages, resize });
37
61
  model.pages.push({ ...page, route: entry.route, source, outFile: entry.outFile });
62
+ });
38
63
  }
39
64
  const notFound = findNotFoundPage(pagesDir);
40
65
  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" };
66
+ await attempt(notFound, undefined, async () => {
67
+ const page = await renderPage({ root, file: notFound, route: "/404/", site, load, notFound: true, analytics: true, pages, resize });
68
+ model.notFound = { ...page, route: "/404/", source: path.relative(root, notFound), outFile: "404.html" };
69
+ });
43
70
  }
44
71
  checkPublicDir(root, model.warnings);
72
+ // Markdown versions of pages, converted from their sanitized HTML (copies and llms-full.txt).
73
+ const markdown = new Map();
74
+ if (site.seo.markdownCopies || (site.seo.llmsTxt && site.seo.llmsTxt.full)) {
75
+ for (const p of listedPages(model))
76
+ markdown.set(p.route, await htmlToMarkdown(p.content, { route: p.route, origin: site.site }));
77
+ }
45
78
  model.redirects = buildRedirects(site, new Set(model.pages.map((p) => p.route)));
46
- addDiscoveryFiles(root, model);
79
+ addDiscoveryFiles(root, model, markdown);
47
80
  return model;
48
81
  }
49
82
  /** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
50
83
  export function listedPages(model) {
51
84
  return model.pages.filter((p) => !p.meta.noindex && p.meta.canonical === absoluteUrl(model.site.site, p.route));
52
85
  }
53
- function addDiscoveryFiles(root, model) {
86
+ function addDiscoveryFiles(root, model, markdown) {
54
87
  const { site, files, warnings } = model;
55
88
  const listed = listedPages(model);
56
89
  const generate = (name, content) => {
90
+ if (files.has(name))
91
+ throw new VageError(`Two generated files would be written to ${name}. Rename one of the pages behind it.`);
57
92
  if (fs.existsSync(path.join(root, "public", name))) {
58
93
  throw new VageError(`public/${name} conflicts with the generated ${name}. Delete it, or turn generation off in vage.config.ts (seo).`);
59
94
  }
@@ -73,14 +108,37 @@ function addDiscoveryFiles(root, model) {
73
108
  if (listed.length > SITEMAP_URL_LIMIT) {
74
109
  throw new VageError(`sitemap.xml would list ${listed.length} URLs; sitemap indexes (over ${SITEMAP_URL_LIMIT}) are not supported yet.`);
75
110
  }
76
- generate("sitemap.xml", () => sitemapXml(listed.map((p) => ({ url: p.meta.canonical, lastmod: p.frontmatter.dateModified ?? p.frontmatter.datePublished }))));
111
+ let gitDates;
112
+ if (site.seo.lastmod === "git") {
113
+ const result = gitPageDates(root);
114
+ gitDates = result.dates;
115
+ if (result.problem)
116
+ warnings.push(`seo.lastmod is "git", but ${result.problem}; sitemap dates come from frontmatter instead.`);
117
+ }
118
+ generate("sitemap.xml", () => sitemapXml(listed.map((p) => ({ url: p.meta.canonical, lastmod: p.frontmatter.dateModified ?? gitDates?.get(p.source) ?? p.frontmatter.datePublished }))));
119
+ }
120
+ for (const feed of site.seo.feeds) {
121
+ if (!site.site) {
122
+ warnings.push(`seo.feeds: the feed for ${feed.under} needs "site" in vage.config.ts and was skipped.`);
123
+ continue;
124
+ }
125
+ if (!listed.some((p) => p.route.startsWith(feed.under) && p.route !== feed.under)) {
126
+ warnings.push(`seo.feeds: no published pages under ${feed.under}, so its feed is empty.`);
127
+ }
128
+ generate(feedPath(feed.under), () => rssFeed(site, feed, listed, site.site));
129
+ }
130
+ if (site.seo.markdownCopies) {
131
+ for (const p of listed)
132
+ generate(markdownCopyPath(p.route), () => markdownCopy(p.frontmatter.title, p.frontmatter.description, markdown.get(p.route) ?? ""));
77
133
  }
78
134
  const indexNow = site.integrations.indexNow;
79
135
  if (indexNow)
80
136
  generate(`${indexNow.key}.txt`, () => `${indexNow.key}\n`);
81
- if (site.deploy.target === "cloudflare-pages" && model.redirects.length)
137
+ // Cloudflare Pages and Workers static assets both read _redirects and _headers.
138
+ const cloudflare = site.deploy.target === "cloudflare-pages" || site.deploy.target === "cloudflare-workers";
139
+ if (cloudflare && model.redirects.length)
82
140
  generate("_redirects", () => cloudflareRedirects(model.redirects));
83
- if (site.deploy.target === "cloudflare-pages" && site.security.headers) {
141
+ if (cloudflare && site.security.headers) {
84
142
  const html = [...model.pages, ...(model.notFound ? [model.notFound] : []), ...model.redirects].map((p) => p.html);
85
143
  generate("_headers", () => headersFile(html, site));
86
144
  }
@@ -91,9 +149,10 @@ function addDiscoveryFiles(root, model) {
91
149
  else {
92
150
  const pages = listed.map((p) => ({
93
151
  title: p.frontmatter.title,
94
- url: absoluteUrl(site.site, p.route) ?? p.route,
152
+ // With Markdown copies, AI tools are pointed at the plain-text version (llmstxt.org).
153
+ url: site.seo.markdownCopies ? (absoluteUrl(site.site, `/${markdownCopyPath(p.route)}`) ?? `/${markdownCopyPath(p.route)}`) : (absoluteUrl(site.site, p.route) ?? p.route),
95
154
  description: p.frontmatter.description,
96
- body: p.body,
155
+ body: markdown.get(p.route) ?? "",
97
156
  }));
98
157
  generate("llms.txt", () => llmsTxt(site.title, site.description, pages));
99
158
  if (site.seo.llmsTxt.full)
@@ -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>;