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.
- package/README.md +190 -25
- package/dist/checks/check.d.ts +2 -0
- package/dist/checks/check.js +106 -2
- package/dist/checks/integrity.d.ts +36 -0
- package/dist/checks/integrity.js +145 -0
- package/dist/checks/links.d.ts +18 -0
- package/dist/checks/links.js +47 -0
- package/dist/checks/verify.d.ts +35 -0
- package/dist/checks/verify.js +167 -0
- package/dist/cli/index.js +116 -10
- package/dist/cli/info.d.ts +35 -0
- package/dist/cli/info.js +49 -4
- package/dist/content/images.d.ts +23 -0
- package/dist/content/images.js +96 -0
- package/dist/content/markdown.d.ts +2 -0
- package/dist/content/markdown.js +6 -1
- package/dist/content/sanitize.d.ts +11 -2
- package/dist/content/sanitize.js +49 -9
- package/dist/core/clone.d.ts +6 -0
- package/dist/core/clone.js +16 -0
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.js +21 -4
- package/dist/core/errors.d.ts +2 -0
- package/dist/core/errors.js +3 -0
- package/dist/core/html.d.ts +22 -0
- package/dist/core/html.js +31 -0
- package/dist/core/pages.d.ts +40 -0
- package/dist/core/pages.js +93 -0
- package/dist/core/render.d.ts +17 -1
- package/dist/core/render.js +51 -9
- package/dist/core/site.d.ts +12 -1
- package/dist/core/site.js +36 -13
- package/dist/core/validate.d.ts +2 -0
- package/dist/core/validate.js +6 -0
- package/dist/deploy/cloudflare-pages.js +3 -1
- package/dist/deploy/cloudflare-workers.d.ts +59 -0
- package/dist/deploy/cloudflare-workers.js +223 -0
- package/dist/deploy/headers.js +1 -1
- package/dist/deploy/publish.d.ts +1 -1
- package/dist/deploy/publish.js +3 -3
- package/dist/deploy/redirects.js +1 -1
- package/dist/deploy/secrets.js +1 -1
- package/dist/dev/server.js +3 -1
- package/dist/guidelines/rule-pack.json +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/integrations/api-keys.d.ts +1 -1
- package/dist/integrations/api-keys.js +1 -1
- package/dist/integrations/google-auth.js +1 -1
- package/dist/integrations/tags.d.ts +1 -1
- package/dist/integrations/tags.js +1 -1
- package/dist/seo/head.js +1 -1
- package/package.json +1 -1
- package/templates/starter/.github/dependabot.yml +11 -0
- package/templates/starter/.github/workflows/ci.yml +2 -0
- package/templates/starter/.github/workflows/verify.yml +32 -0
- package/templates/starter/AGENTS.md +36 -15
- package/templates/starter/links.lock.json +4 -0
- package/templates/starter/package.json +1 -1
- package/templates/starter/src/layouts/blog.ts +13 -0
- package/templates/starter/src/layouts/default.ts +8 -7
- package/templates/starter/src/pages/blog/index.md +7 -0
- package/templates/starter/src/pages/index.md +2 -2
- package/templates/starter/vage.config.ts +5 -1
package/dist/core/render.js
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/core/site.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|
package/dist/core/validate.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/validate.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/deploy/headers.js
CHANGED
|
@@ -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
|
|
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)}`);
|
package/dist/deploy/publish.d.ts
CHANGED
|
@@ -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
|
|
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). */
|
package/dist/deploy/publish.js
CHANGED
|
@@ -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.
|
|
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;
|
|
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
|
|
132
|
+
message: options.message ?? "Publish site with vagejs",
|
|
133
133
|
unpushedCommits,
|
|
134
134
|
warnings,
|
|
135
135
|
};
|
package/dist/deploy/redirects.js
CHANGED
|
@@ -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
|
|
17
|
+
return `# Generated by vagejs from redirects in vage.config.ts\n${lines.join("\n")}\n`;
|
|
18
18
|
}
|
package/dist/deploy/secrets.js
CHANGED
|
@@ -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$/, "
|
|
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"],
|
package/dist/dev/server.js
CHANGED
|
@@ -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
|
|
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
|
|
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";
|