vagejs 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +244 -2
- package/dist/audit/a11y.d.ts +13 -0
- package/dist/audit/a11y.js +33 -0
- package/dist/audit/audit.d.ts +31 -0
- package/dist/audit/audit.js +143 -0
- package/dist/audit/budgets.d.ts +22 -0
- package/dist/audit/budgets.js +106 -0
- package/dist/audit/lighthouse.d.ts +3 -0
- package/dist/audit/lighthouse.js +48 -0
- package/dist/audit/remote.d.ts +65 -0
- package/dist/audit/remote.js +123 -0
- package/dist/audit/vitals.d.ts +28 -0
- package/dist/audit/vitals.js +18 -0
- package/dist/checks/check.d.ts +31 -0
- package/dist/checks/check.js +434 -0
- 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/create.d.ts +10 -0
- package/dist/cli/create.js +39 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +710 -0
- package/dist/cli/info.d.ts +82 -0
- package/dist/cli/info.js +100 -0
- package/dist/content/images.d.ts +23 -0
- package/dist/content/images.js +96 -0
- package/dist/content/markdown.d.ts +28 -0
- package/dist/content/markdown.js +56 -0
- package/dist/content/sanitize.d.ts +16 -0
- package/dist/content/sanitize.js +57 -0
- package/dist/core/build.d.ts +25 -0
- package/dist/core/build.js +54 -0
- package/dist/core/clone.d.ts +6 -0
- package/dist/core/clone.js +16 -0
- package/dist/core/config.d.ts +180 -0
- package/dist/core/config.js +260 -0
- package/dist/core/errors.d.ts +7 -0
- package/dist/core/errors.js +12 -0
- package/dist/core/html.d.ts +24 -0
- package/dist/core/html.js +42 -0
- package/dist/core/loader.d.ts +10 -0
- package/dist/core/loader.js +16 -0
- package/dist/core/pages.d.ts +40 -0
- package/dist/core/pages.js +93 -0
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +14 -0
- package/dist/core/render.d.ts +53 -0
- package/dist/core/render.js +104 -0
- package/dist/core/routes.d.ts +24 -0
- package/dist/core/routes.js +70 -0
- package/dist/core/site.d.ts +42 -0
- package/dist/core/site.js +182 -0
- package/dist/core/validate.d.ts +12 -0
- package/dist/core/validate.js +36 -0
- package/dist/deploy/cloudflare-pages.d.ts +20 -0
- package/dist/deploy/cloudflare-pages.js +42 -0
- package/dist/deploy/cloudflare-workers.d.ts +59 -0
- package/dist/deploy/cloudflare-workers.js +223 -0
- package/dist/deploy/git.d.ts +20 -0
- package/dist/deploy/git.js +55 -0
- package/dist/deploy/github-pages.d.ts +33 -0
- package/dist/deploy/github-pages.js +99 -0
- package/dist/deploy/headers.d.ts +11 -0
- package/dist/deploy/headers.js +103 -0
- package/dist/deploy/publish.d.ts +45 -0
- package/dist/deploy/publish.js +148 -0
- package/dist/deploy/redirects.d.ts +7 -0
- package/dist/deploy/redirects.js +18 -0
- package/dist/deploy/secrets.d.ts +6 -0
- package/dist/deploy/secrets.js +66 -0
- package/dist/dev/guard.d.ts +9 -0
- package/dist/dev/guard.js +57 -0
- package/dist/dev/listen.d.ts +6 -0
- package/dist/dev/listen.js +13 -0
- package/dist/dev/preview.d.ts +6 -0
- package/dist/dev/preview.js +72 -0
- package/dist/dev/server.d.ts +8 -0
- package/dist/dev/server.js +104 -0
- package/dist/discovery/crawlers.d.ts +13 -0
- package/dist/discovery/crawlers.js +2 -0
- package/dist/discovery/llms.d.ts +12 -0
- package/dist/discovery/llms.js +19 -0
- package/dist/discovery/robots.d.ts +8 -0
- package/dist/discovery/robots.js +40 -0
- package/dist/discovery/sitemap.d.ts +8 -0
- package/dist/discovery/sitemap.js +11 -0
- package/dist/guidelines/monitor.d.ts +55 -0
- package/dist/guidelines/monitor.js +126 -0
- package/dist/guidelines/pack.d.ts +41 -0
- package/dist/guidelines/pack.js +13 -0
- package/dist/guidelines/rule-pack.json +611 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/integrations/api-keys.d.ts +26 -0
- package/dist/integrations/api-keys.js +65 -0
- package/dist/integrations/ga4-data.d.ts +13 -0
- package/dist/integrations/ga4-data.js +39 -0
- package/dist/integrations/google-auth.d.ts +47 -0
- package/dist/integrations/google-auth.js +157 -0
- package/dist/integrations/http.d.ts +15 -0
- package/dist/integrations/http.js +40 -0
- package/dist/integrations/indexnow.d.ts +18 -0
- package/dist/integrations/indexnow.js +38 -0
- package/dist/integrations/search-console.d.ts +25 -0
- package/dist/integrations/search-console.js +39 -0
- package/dist/integrations/tags.d.ts +22 -0
- package/dist/integrations/tags.js +67 -0
- package/dist/seo/head.d.ts +20 -0
- package/dist/seo/head.js +85 -0
- package/dist/seo/jsonld.d.ts +35 -0
- package/dist/seo/jsonld.js +31 -0
- package/dist/seo/urls.d.ts +3 -0
- package/dist/seo/urls.js +10 -0
- package/package.json +81 -4
- package/templates/starter/.env.example +12 -0
- package/templates/starter/.github/dependabot.yml +11 -0
- package/templates/starter/.github/workflows/ci.yml +30 -0
- package/templates/starter/.github/workflows/verify.yml +32 -0
- package/templates/starter/AGENTS.md +166 -0
- package/templates/starter/CLAUDE.md +3 -0
- package/templates/starter/_gitignore +8 -0
- package/templates/starter/links.lock.json +4 -0
- package/templates/starter/package.json +16 -0
- package/templates/starter/public/apple-touch-icon.png +0 -0
- package/templates/starter/public/favicon.ico +0 -0
- package/templates/starter/public/favicon.svg +1 -0
- package/templates/starter/src/layouts/blog.ts +13 -0
- package/templates/starter/src/layouts/default.ts +38 -0
- package/templates/starter/src/pages/404.md +7 -0
- package/templates/starter/src/pages/about.md +8 -0
- package/templates/starter/src/pages/blog/first-post.md +10 -0
- package/templates/starter/src/pages/blog/index.md +7 -0
- package/templates/starter/src/pages/index.md +8 -0
- package/templates/starter/vage.config.ts +38 -0
|
@@ -0,0 +1,93 @@
|
|
|
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 { applyLayoutFields } from "./render.js";
|
|
7
|
+
import { VageError } from "./errors.js";
|
|
8
|
+
import { discoverRoutes } from "./routes.js";
|
|
9
|
+
/**
|
|
10
|
+
* Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
|
|
11
|
+
* and applies each page's layout schema, so list entries get the same defaults as the
|
|
12
|
+
* page itself. The result is frozen: layouts share it, so none may change it.
|
|
13
|
+
* `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
|
|
14
|
+
* they report their own error instead of breaking every page that lists others.
|
|
15
|
+
*/
|
|
16
|
+
export async function collectPages(root, site, load, options = {}) {
|
|
17
|
+
const pagesDir = path.join(root, "src", "pages");
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const entry of discoverRoutes(pagesDir)) {
|
|
20
|
+
let frontmatter;
|
|
21
|
+
try {
|
|
22
|
+
({ frontmatter } = parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file));
|
|
23
|
+
if (frontmatter.draft)
|
|
24
|
+
continue;
|
|
25
|
+
await applyLayoutFields(root, frontmatter, load, entry.file);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
// Any failure (bad frontmatter, a layout that throws or doesn't compile) is that page's own error.
|
|
29
|
+
if (options.skipInvalid)
|
|
30
|
+
continue;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
out.push({
|
|
34
|
+
route: entry.route,
|
|
35
|
+
url: pageMeta(site, entry.route, frontmatter).canonical,
|
|
36
|
+
title: frontmatter.title,
|
|
37
|
+
description: frontmatter.description,
|
|
38
|
+
source: path.relative(root, entry.file),
|
|
39
|
+
frontmatter,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// A frozen deep copy: shared by every layout, and never the objects a page renders with.
|
|
43
|
+
return deepFreeze(clonePlain(out));
|
|
44
|
+
}
|
|
45
|
+
/** Freezes plain data only; URL/Date/class instances from schema transforms stay usable. */
|
|
46
|
+
function deepFreeze(value) {
|
|
47
|
+
const plain = Array.isArray(value) || (value && typeof value === "object" && [Object.prototype, null].includes(Object.getPrototypeOf(value)));
|
|
48
|
+
if (plain && !Object.isFrozen(value)) {
|
|
49
|
+
Object.freeze(value);
|
|
50
|
+
for (const v of Object.values(value))
|
|
51
|
+
deepFreeze(v);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
const SORTS = ["date", "title", "route"];
|
|
56
|
+
/**
|
|
57
|
+
* Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
|
|
58
|
+
* collection(pages, { under: "/blog/", sort: "date", limit: 5 })
|
|
59
|
+
*/
|
|
60
|
+
export function collection(pages, options = {}) {
|
|
61
|
+
if (!Array.isArray(pages))
|
|
62
|
+
throw new VageError("collection(): pass the layout's `pages` as the first argument.");
|
|
63
|
+
const { under, includeIndex = false, sort = "route", limit } = options;
|
|
64
|
+
if (under !== undefined && !/^\/(?!\/)/.test(under)) {
|
|
65
|
+
throw new VageError(`collection(): "under" must be a site path starting with "/", like "/blog/" (got ${JSON.stringify(under)}).`);
|
|
66
|
+
}
|
|
67
|
+
if (typeof sort !== "function" && !SORTS.includes(sort)) {
|
|
68
|
+
throw new VageError(`collection(): "sort" must be "date", "title", "route" or a compare function (got ${JSON.stringify(sort)}).`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof includeIndex !== "boolean")
|
|
71
|
+
throw new VageError(`collection(): "includeIndex" must be true or false (got ${JSON.stringify(includeIndex)}).`);
|
|
72
|
+
if (limit !== undefined && !(Number.isInteger(limit) && limit >= 0)) {
|
|
73
|
+
throw new VageError(`collection(): "limit" must be a whole number of 0 or more (got ${JSON.stringify(limit)}).`);
|
|
74
|
+
}
|
|
75
|
+
const prefix = under ? (under.endsWith("/") ? under : `${under}/`) : undefined;
|
|
76
|
+
const picked = pages.filter((p) => !prefix || (p.route.startsWith(prefix) && (includeIndex || p.route !== prefix)));
|
|
77
|
+
const byTitle = (a, b) => a.title.localeCompare(b.title);
|
|
78
|
+
const compare = typeof sort === "function"
|
|
79
|
+
? sort
|
|
80
|
+
: sort === "title"
|
|
81
|
+
? byTitle
|
|
82
|
+
: sort === "date"
|
|
83
|
+
? (a, b) => {
|
|
84
|
+
const da = a.frontmatter.datePublished ?? "";
|
|
85
|
+
const db = b.frontmatter.datePublished ?? "";
|
|
86
|
+
if (da !== db)
|
|
87
|
+
return da && db ? db.localeCompare(da) : da ? -1 : 1;
|
|
88
|
+
return byTitle(a, b);
|
|
89
|
+
}
|
|
90
|
+
: (a, b) => a.route.localeCompare(b.route);
|
|
91
|
+
const sorted = [...picked].sort(compare);
|
|
92
|
+
return limit === undefined ? sorted : sorted.slice(0, limit);
|
|
93
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { findConfigFile, loadConfig, parseConfig } from "./config.js";
|
|
2
|
+
import { createBuildLoader } from "./loader.js";
|
|
3
|
+
/** Loads vage.config.* outside a build (defaults when there is none). */
|
|
4
|
+
export async function loadProjectConfig(root) {
|
|
5
|
+
if (!findConfigFile(root))
|
|
6
|
+
return parseConfig({});
|
|
7
|
+
const loader = await createBuildLoader(root);
|
|
8
|
+
try {
|
|
9
|
+
return await loadConfig(root, loader.load);
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
await loader.close();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Frontmatter } from "../content/markdown.js";
|
|
2
|
+
import type { ModuleLoader, SiteConfig } from "./config.js";
|
|
3
|
+
import { type SafeHtml } from "./html.js";
|
|
4
|
+
import { type PageMeta } from "../seo/head.js";
|
|
5
|
+
import type { PageSummary } from "./pages.js";
|
|
6
|
+
export interface LayoutProps {
|
|
7
|
+
/** Rendered page body HTML. Insert as-is; do not escape. */
|
|
8
|
+
content: string;
|
|
9
|
+
page: {
|
|
10
|
+
route: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
/** Absolute canonical URL, when `site` is set and the page is indexable. */
|
|
14
|
+
url?: string;
|
|
15
|
+
frontmatter: Frontmatter;
|
|
16
|
+
};
|
|
17
|
+
site: SiteConfig;
|
|
18
|
+
/**
|
|
19
|
+
* Every published page (drafts excluded), sorted by route. Use `collection()` to
|
|
20
|
+
* pick a section and order it, e.g. a blog index or a sidebar.
|
|
21
|
+
*/
|
|
22
|
+
pages: readonly PageSummary[];
|
|
23
|
+
}
|
|
24
|
+
/** Returns the page's HTML: a string, or `html\`...\`` (escapes values automatically). */
|
|
25
|
+
export type Layout = (props: LayoutProps) => string | SafeHtml | Promise<string | SafeHtml>;
|
|
26
|
+
export interface RenderedPage {
|
|
27
|
+
html: string;
|
|
28
|
+
frontmatter: Frontmatter;
|
|
29
|
+
/** Markdown body without frontmatter. */
|
|
30
|
+
body: string;
|
|
31
|
+
meta: PageMeta;
|
|
32
|
+
}
|
|
33
|
+
export declare function renderPage(options: {
|
|
34
|
+
root: string;
|
|
35
|
+
file: string;
|
|
36
|
+
route: string;
|
|
37
|
+
site: SiteConfig;
|
|
38
|
+
load: ModuleLoader;
|
|
39
|
+
/** Renders the 404 page: always noindex, no canonical. */
|
|
40
|
+
notFound?: boolean;
|
|
41
|
+
/** Add configured analytics tags. Off in the dev server so development traffic is never measured. */
|
|
42
|
+
analytics?: boolean;
|
|
43
|
+
/** Published pages for `props.pages`. */
|
|
44
|
+
pages?: readonly PageSummary[];
|
|
45
|
+
}): Promise<RenderedPage>;
|
|
46
|
+
/**
|
|
47
|
+
* Loads the page's layout and, if it exports a `frontmatter` schema, validates the page
|
|
48
|
+
* against it and fills in defaults (mutating `frontmatter`). Used for rendering and for
|
|
49
|
+
* `props.pages`, so both see the same values. Returns the layout function.
|
|
50
|
+
*/
|
|
51
|
+
export declare function applyLayoutFields(root: string, frontmatter: Frontmatter, load: ModuleLoader, file: string): Promise<Layout>;
|
|
52
|
+
/** Used when a project has no src/layouts/default.ts. */
|
|
53
|
+
export declare const defaultLayout: Layout;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { frontmatterSchema, parseMarkdown } from "../content/markdown.js";
|
|
5
|
+
import { enhanceTrustedHtml, sanitizeHtml } from "../content/sanitize.js";
|
|
6
|
+
import { VageError } from "./errors.js";
|
|
7
|
+
import { clonePlain } from "./clone.js";
|
|
8
|
+
import { escapeHtml, isSafeHtml } from "./html.js";
|
|
9
|
+
import { headTags, injectHead, pageMeta } from "../seo/head.js";
|
|
10
|
+
import { analyticsTags, injectAnalytics } from "../integrations/tags.js";
|
|
11
|
+
export async function renderPage(options) {
|
|
12
|
+
const { root, file, route, site, load } = options;
|
|
13
|
+
const parsed = parseMarkdown(fs.readFileSync(file, "utf8"), file);
|
|
14
|
+
const { frontmatter, body } = parsed;
|
|
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);
|
|
18
|
+
const meta = pageMeta(site, route, frontmatter, options.notFound);
|
|
19
|
+
const result = await layout({
|
|
20
|
+
content,
|
|
21
|
+
page: { route, title: frontmatter.title, description: frontmatter.description, url: meta.canonical, frontmatter },
|
|
22
|
+
site,
|
|
23
|
+
pages: options.pages ?? [],
|
|
24
|
+
});
|
|
25
|
+
const html = isSafeHtml(result) ? result.value : result;
|
|
26
|
+
if (typeof html !== "string") {
|
|
27
|
+
throw new VageError(`Layout "${frontmatter.layout ?? "default"}" must return an HTML string or html\`...\`.`, file);
|
|
28
|
+
}
|
|
29
|
+
let out = injectHead(html, headTags(site, meta), file);
|
|
30
|
+
const tags = options.analytics ? analyticsTags(site) : undefined;
|
|
31
|
+
if (tags)
|
|
32
|
+
out = injectAnalytics(out, tags, file);
|
|
33
|
+
return { html: out, frontmatter, body, meta };
|
|
34
|
+
}
|
|
35
|
+
const LAYOUT_EXTENSIONS = [".ts", ".js", ".mjs"];
|
|
36
|
+
async function resolveLayout(root, name, load, pageFile) {
|
|
37
|
+
const base = path.join(root, "src", "layouts", name ?? "default");
|
|
38
|
+
const file = LAYOUT_EXTENSIONS.map((ext) => base + ext).find((f) => fs.existsSync(f));
|
|
39
|
+
if (!file) {
|
|
40
|
+
if (!name)
|
|
41
|
+
return { layout: defaultLayout };
|
|
42
|
+
throw new VageError(`Layout "${name}" not found. Expected src/layouts/${name}.ts`, pageFile);
|
|
43
|
+
}
|
|
44
|
+
const mod = await load(file);
|
|
45
|
+
const layout = mod.default;
|
|
46
|
+
if (typeof layout !== "function") {
|
|
47
|
+
throw new VageError("Layout files must default-export a function that returns an HTML string.", file);
|
|
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));
|
|
65
|
+
return layout;
|
|
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
|
+
}
|
|
87
|
+
/** Used when a project has no src/layouts/default.ts. */
|
|
88
|
+
export const defaultLayout = ({ content, page, site }) => {
|
|
89
|
+
const title = site.title && site.title !== page.title ? `${page.title} | ${site.title}` : page.title;
|
|
90
|
+
return `<!doctype html>
|
|
91
|
+
<html lang="${escapeHtml(site.lang)}">
|
|
92
|
+
<head>
|
|
93
|
+
<meta charset="utf-8" />
|
|
94
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
95
|
+
<title>${escapeHtml(title)}</title>
|
|
96
|
+
</head>
|
|
97
|
+
<body>
|
|
98
|
+
<main>
|
|
99
|
+
${content}
|
|
100
|
+
</main>
|
|
101
|
+
</body>
|
|
102
|
+
</html>
|
|
103
|
+
`;
|
|
104
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface RouteEntry {
|
|
2
|
+
/** URL path, always with a trailing slash, e.g. "/blog/first-post/". */
|
|
3
|
+
route: string;
|
|
4
|
+
/** Absolute path of the source file. */
|
|
5
|
+
file: string;
|
|
6
|
+
/** Output path relative to the build directory, e.g. "blog/first-post/index.html". */
|
|
7
|
+
outFile: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const NOT_FOUND_PAGE = "404.md";
|
|
10
|
+
/**
|
|
11
|
+
* Maps src/pages/**\/*.md to routes:
|
|
12
|
+
* index.md -> /
|
|
13
|
+
* about.md -> /about/
|
|
14
|
+
* blog/index.md -> /blog/
|
|
15
|
+
* blog/post.md -> /blog/post/
|
|
16
|
+
* Files and directories starting with "_" are ignored. The top-level 404.md is
|
|
17
|
+
* not a route; use findNotFoundPage() for it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function discoverRoutes(pagesDir: string): RouteEntry[];
|
|
20
|
+
/** Route for a page path relative to src/pages (works for deleted files too). */
|
|
21
|
+
export declare function routeForPage(rel: string): string | undefined;
|
|
22
|
+
export declare function findNotFoundPage(pagesDir: string): string | undefined;
|
|
23
|
+
/** Normalizes a request path ("/about", "/about/index.html") to a route key ("/about/"). */
|
|
24
|
+
export declare function normalizeRequestPath(urlPath: string): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { VageError } from "./errors.js";
|
|
4
|
+
export const NOT_FOUND_PAGE = "404.md";
|
|
5
|
+
/**
|
|
6
|
+
* Maps src/pages/**\/*.md to routes:
|
|
7
|
+
* index.md -> /
|
|
8
|
+
* about.md -> /about/
|
|
9
|
+
* blog/index.md -> /blog/
|
|
10
|
+
* blog/post.md -> /blog/post/
|
|
11
|
+
* Files and directories starting with "_" are ignored. The top-level 404.md is
|
|
12
|
+
* not a route; use findNotFoundPage() for it.
|
|
13
|
+
*/
|
|
14
|
+
export function discoverRoutes(pagesDir) {
|
|
15
|
+
if (!fs.existsSync(pagesDir))
|
|
16
|
+
return [];
|
|
17
|
+
const routes = new Map();
|
|
18
|
+
for (const rel of walk(pagesDir)) {
|
|
19
|
+
if (rel === NOT_FOUND_PAGE)
|
|
20
|
+
continue;
|
|
21
|
+
const segments = routeSegments(rel);
|
|
22
|
+
const route = segments.length ? `/${segments.join("/")}/` : "/";
|
|
23
|
+
const file = path.join(pagesDir, rel);
|
|
24
|
+
const existing = routes.get(route);
|
|
25
|
+
if (existing) {
|
|
26
|
+
throw new VageError(`Two pages map to the route "${route}": ${path.relative(pagesDir, existing.file)} and ${rel}. Remove or rename one of them.`, file);
|
|
27
|
+
}
|
|
28
|
+
routes.set(route, { route, file, outFile: path.posix.join(...segments, "index.html") });
|
|
29
|
+
}
|
|
30
|
+
return [...routes.values()].sort((a, b) => a.route.localeCompare(b.route));
|
|
31
|
+
}
|
|
32
|
+
function routeSegments(rel) {
|
|
33
|
+
const segments = rel.slice(0, -".md".length).split("/");
|
|
34
|
+
if (segments.at(-1) === "index")
|
|
35
|
+
segments.pop();
|
|
36
|
+
return segments;
|
|
37
|
+
}
|
|
38
|
+
/** Route for a page path relative to src/pages (works for deleted files too). */
|
|
39
|
+
export function routeForPage(rel) {
|
|
40
|
+
if (!rel.endsWith(".md") || rel === NOT_FOUND_PAGE || rel.split("/").some((s) => s.startsWith("_") || s.startsWith(".")))
|
|
41
|
+
return undefined;
|
|
42
|
+
const segments = routeSegments(rel);
|
|
43
|
+
return segments.length ? `/${segments.join("/")}/` : "/";
|
|
44
|
+
}
|
|
45
|
+
export function findNotFoundPage(pagesDir) {
|
|
46
|
+
const file = path.join(pagesDir, NOT_FOUND_PAGE);
|
|
47
|
+
return fs.existsSync(file) ? file : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Normalizes a request path ("/about", "/about/index.html") to a route key ("/about/"). */
|
|
50
|
+
export function normalizeRequestPath(urlPath) {
|
|
51
|
+
let p = decodeURIComponent(urlPath.split(/[?#]/)[0] ?? "/");
|
|
52
|
+
if (p.endsWith("/index.html"))
|
|
53
|
+
p = p.slice(0, -"index.html".length);
|
|
54
|
+
else if (p.endsWith(".html"))
|
|
55
|
+
p = p.slice(0, -".html".length);
|
|
56
|
+
return p.endsWith("/") ? p : `${p}/`;
|
|
57
|
+
}
|
|
58
|
+
function walk(dir, prefix = "") {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
61
|
+
if (entry.name.startsWith("_") || entry.name.startsWith("."))
|
|
62
|
+
continue;
|
|
63
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
64
|
+
if (entry.isDirectory())
|
|
65
|
+
out.push(...walk(path.join(dir, entry.name), rel));
|
|
66
|
+
else if (entry.isFile() && entry.name.endsWith(".md"))
|
|
67
|
+
out.push(rel);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ModuleLoader, type SiteConfig } from "./config.js";
|
|
2
|
+
import { type RenderedPage } from "./render.js";
|
|
3
|
+
export interface SitePage extends RenderedPage {
|
|
4
|
+
route: string;
|
|
5
|
+
/** Source path relative to the project root. */
|
|
6
|
+
source: string;
|
|
7
|
+
outFile: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SiteRedirect {
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
outFile: string;
|
|
13
|
+
html: string;
|
|
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
|
+
}
|
|
22
|
+
export interface SiteModel {
|
|
23
|
+
site: SiteConfig;
|
|
24
|
+
/** Pages that failed to render (only with `collectErrors`; otherwise the first failure throws). */
|
|
25
|
+
pageErrors: PageError[];
|
|
26
|
+
/** Published pages (drafts excluded). */
|
|
27
|
+
pages: SitePage[];
|
|
28
|
+
skippedDrafts: string[];
|
|
29
|
+
/** Routes of draft pages; links to them are broken in production. */
|
|
30
|
+
draftRoutes: string[];
|
|
31
|
+
notFound?: SitePage;
|
|
32
|
+
redirects: SiteRedirect[];
|
|
33
|
+
/** Generated discovery files: output path -> content. */
|
|
34
|
+
files: Map<string, string>;
|
|
35
|
+
warnings: string[];
|
|
36
|
+
}
|
|
37
|
+
/** Renders every page and derives discovery files from the same data, without writing anything. */
|
|
38
|
+
export declare function renderSite(root: string, load: ModuleLoader, options?: {
|
|
39
|
+
collectErrors?: boolean;
|
|
40
|
+
}): Promise<SiteModel>;
|
|
41
|
+
/** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
|
|
42
|
+
export declare function listedPages(model: SiteModel): SitePage[];
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { llmsFullTxt, llmsTxt } from "../discovery/llms.js";
|
|
4
|
+
import { robotsTxt } from "../discovery/robots.js";
|
|
5
|
+
import { SITEMAP_URL_LIMIT, sitemapXml } from "../discovery/sitemap.js";
|
|
6
|
+
import { headersFile } from "../deploy/headers.js";
|
|
7
|
+
import { cloudflareRedirects } from "../deploy/redirects.js";
|
|
8
|
+
import { findSecrets } from "../deploy/secrets.js";
|
|
9
|
+
import { rulePack } from "../guidelines/pack.js";
|
|
10
|
+
import { absoluteUrl } from "../seo/urls.js";
|
|
11
|
+
import { loadConfig } from "./config.js";
|
|
12
|
+
import { VageError } from "./errors.js";
|
|
13
|
+
import { escapeHtml } from "./html.js";
|
|
14
|
+
import { renderPage } from "./render.js";
|
|
15
|
+
import { parseFrontmatter } from "../content/markdown.js";
|
|
16
|
+
import { collectPages } from "./pages.js";
|
|
17
|
+
import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "./routes.js";
|
|
18
|
+
/** Renders every page and derives discovery files from the same data, without writing anything. */
|
|
19
|
+
export async function renderSite(root, load, options = {}) {
|
|
20
|
+
const pagesDir = path.join(root, "src", "pages");
|
|
21
|
+
if (!fs.existsSync(pagesDir)) {
|
|
22
|
+
throw new VageError(`No src/pages directory found in ${root}. Is this a vagejs project?`);
|
|
23
|
+
}
|
|
24
|
+
const site = await loadConfig(root, load);
|
|
25
|
+
const pinned = site.guidelines.rulePack;
|
|
26
|
+
if (pinned && pinned !== rulePack.version) {
|
|
27
|
+
throw new VageError(`This project pins rule pack ${pinned}, but the installed vagejs ships ${rulePack.version}. ` +
|
|
28
|
+
`Review what changed with "vage guidelines", then update guidelines.rulePack in vage.config.ts.`);
|
|
29
|
+
}
|
|
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 });
|
|
45
|
+
for (const entry of discoverRoutes(pagesDir)) {
|
|
46
|
+
const source = path.relative(root, entry.file);
|
|
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 });
|
|
55
|
+
model.pages.push({ ...page, route: entry.route, source, outFile: entry.outFile });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const notFound = findNotFoundPage(pagesDir);
|
|
59
|
+
if (notFound) {
|
|
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
|
+
});
|
|
64
|
+
}
|
|
65
|
+
checkPublicDir(root, model.warnings);
|
|
66
|
+
model.redirects = buildRedirects(site, new Set(model.pages.map((p) => p.route)));
|
|
67
|
+
addDiscoveryFiles(root, model);
|
|
68
|
+
return model;
|
|
69
|
+
}
|
|
70
|
+
/** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
|
|
71
|
+
export function listedPages(model) {
|
|
72
|
+
return model.pages.filter((p) => !p.meta.noindex && p.meta.canonical === absoluteUrl(model.site.site, p.route));
|
|
73
|
+
}
|
|
74
|
+
function addDiscoveryFiles(root, model) {
|
|
75
|
+
const { site, files, warnings } = model;
|
|
76
|
+
const listed = listedPages(model);
|
|
77
|
+
const generate = (name, content) => {
|
|
78
|
+
if (fs.existsSync(path.join(root, "public", name))) {
|
|
79
|
+
throw new VageError(`public/${name} conflicts with the generated ${name}. Delete it, or turn generation off in vage.config.ts (seo).`);
|
|
80
|
+
}
|
|
81
|
+
files.set(name, content());
|
|
82
|
+
};
|
|
83
|
+
if (site.site && /^https?:\/\/(www\.)?example\.(com|org|net)$/.test(site.site)) {
|
|
84
|
+
warnings.push(`"site" is still the placeholder ${site.site}; canonical URLs and sitemap.xml will point there. Set your real origin.`);
|
|
85
|
+
}
|
|
86
|
+
if (!site.site) {
|
|
87
|
+
warnings.push('"site" is not set in vage.config.ts: canonical URLs, og:url, sitemap.xml and absolute image URLs are skipped.');
|
|
88
|
+
}
|
|
89
|
+
if (site.seo.robots) {
|
|
90
|
+
const robots = site.seo.robots;
|
|
91
|
+
generate("robots.txt", () => robotsTxt(site, robots));
|
|
92
|
+
}
|
|
93
|
+
if (site.seo.sitemap && site.site) {
|
|
94
|
+
if (listed.length > SITEMAP_URL_LIMIT) {
|
|
95
|
+
throw new VageError(`sitemap.xml would list ${listed.length} URLs; sitemap indexes (over ${SITEMAP_URL_LIMIT}) are not supported yet.`);
|
|
96
|
+
}
|
|
97
|
+
generate("sitemap.xml", () => sitemapXml(listed.map((p) => ({ url: p.meta.canonical, lastmod: p.frontmatter.dateModified ?? p.frontmatter.datePublished }))));
|
|
98
|
+
}
|
|
99
|
+
const indexNow = site.integrations.indexNow;
|
|
100
|
+
if (indexNow)
|
|
101
|
+
generate(`${indexNow.key}.txt`, () => `${indexNow.key}\n`);
|
|
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)
|
|
105
|
+
generate("_redirects", () => cloudflareRedirects(model.redirects));
|
|
106
|
+
if (cloudflare && site.security.headers) {
|
|
107
|
+
const html = [...model.pages, ...(model.notFound ? [model.notFound] : []), ...model.redirects].map((p) => p.html);
|
|
108
|
+
generate("_headers", () => headersFile(html, site));
|
|
109
|
+
}
|
|
110
|
+
if (site.seo.llmsTxt) {
|
|
111
|
+
if (!site.title) {
|
|
112
|
+
warnings.push('"title" is not set in vage.config.ts: llms.txt needs a site title and was skipped.');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const pages = listed.map((p) => ({
|
|
116
|
+
title: p.frontmatter.title,
|
|
117
|
+
url: absoluteUrl(site.site, p.route) ?? p.route,
|
|
118
|
+
description: p.frontmatter.description,
|
|
119
|
+
body: p.body,
|
|
120
|
+
}));
|
|
121
|
+
generate("llms.txt", () => llmsTxt(site.title, site.description, pages));
|
|
122
|
+
if (site.seo.llmsTxt.full)
|
|
123
|
+
generate("llms-full.txt", () => llmsFullTxt(site.title, site.description, pages));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Everything in public/ is published. Refuses symlinks (they can point at files
|
|
129
|
+
* outside the project) and files that look like secrets. Google browser API keys
|
|
130
|
+
* are meant to be public when referrer-restricted, so they only warn.
|
|
131
|
+
*/
|
|
132
|
+
function checkPublicDir(root, warnings) {
|
|
133
|
+
const publicDir = path.join(root, "public");
|
|
134
|
+
if (!fs.existsSync(publicDir))
|
|
135
|
+
return;
|
|
136
|
+
const files = [];
|
|
137
|
+
const walk = (dir, prefix) => {
|
|
138
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
139
|
+
const rel = `${prefix}${e.name}`;
|
|
140
|
+
if (e.isSymbolicLink()) {
|
|
141
|
+
throw new VageError(`public/${rel} is a symlink. Copy the real file into public/ instead; links can expose files outside the project.`);
|
|
142
|
+
}
|
|
143
|
+
if (e.isDirectory())
|
|
144
|
+
walk(path.join(dir, e.name), `${rel}/`);
|
|
145
|
+
else
|
|
146
|
+
files.push(rel);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
walk(publicDir, "");
|
|
150
|
+
const hits = findSecrets(publicDir, files);
|
|
151
|
+
const blocking = hits.filter((h) => h.reason !== "looks like a Google API key");
|
|
152
|
+
for (const h of hits.filter((h) => !blocking.includes(h))) {
|
|
153
|
+
warnings.push(`public/${h.path} contains a Google API key and will be public. Fine for a referrer-restricted browser key; otherwise remove it.`);
|
|
154
|
+
}
|
|
155
|
+
if (blocking.length) {
|
|
156
|
+
throw new VageError(`Refusing to publish possible secrets from public/:\n${blocking.map((h) => ` public/${h.path}: ${h.reason}`).join("\n")}\nEverything in public/ is served to the world. Move these files out (and rotate exposed credentials).`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function buildRedirects(site, routes) {
|
|
160
|
+
return Object.entries(site.redirects).map(([fromPath, to]) => {
|
|
161
|
+
const from = normalizeRequestPath(fromPath);
|
|
162
|
+
if (routes.has(from)) {
|
|
163
|
+
throw new VageError(`Redirect source "${fromPath}" is also a page route. Remove the page or the redirect.`);
|
|
164
|
+
}
|
|
165
|
+
const target = absoluteUrl(site.site, to) ?? to;
|
|
166
|
+
const html = `<!doctype html>
|
|
167
|
+
<html>
|
|
168
|
+
<head>
|
|
169
|
+
<meta charset="utf-8" />
|
|
170
|
+
<title>Redirecting…</title>
|
|
171
|
+
<meta name="robots" content="noindex" />
|
|
172
|
+
<link rel="canonical" href="${escapeHtml(target)}" />
|
|
173
|
+
<meta http-equiv="refresh" content="0; url=${escapeHtml(to)}" />
|
|
174
|
+
</head>
|
|
175
|
+
<body>
|
|
176
|
+
<p>This page has moved to <a href="${escapeHtml(to)}">${escapeHtml(to)}</a>.</p>
|
|
177
|
+
</body>
|
|
178
|
+
</html>
|
|
179
|
+
`;
|
|
180
|
+
return { from, to, outFile: path.posix.join(from.slice(1), "index.html"), html };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validators for values that reach other programs (git, wrangler, npm) as
|
|
3
|
+
* arguments. A leading "-" would be read as an option, so it is always refused.
|
|
4
|
+
*/
|
|
5
|
+
export declare function assertBranchName(value: string, flag?: string): string;
|
|
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;
|
|
9
|
+
/** https://, ssh://, git@host:path, or a local path. Refuses options and git's command-running transports. */
|
|
10
|
+
export declare function assertGitRemote(value: string): string;
|
|
11
|
+
/** npm dependency spec for new projects: a version/range, "latest", or a local file: tarball. */
|
|
12
|
+
export declare function assertVageSpec(value: string): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { VageError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validators for values that reach other programs (git, wrangler, npm) as
|
|
4
|
+
* arguments. A leading "-" would be read as an option, so it is always refused.
|
|
5
|
+
*/
|
|
6
|
+
export function assertBranchName(value, flag = "--branch") {
|
|
7
|
+
if (!/^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]{1,200}$/.test(value) || value.endsWith("/") || value.endsWith(".lock")) {
|
|
8
|
+
throw new VageError(`Invalid ${flag} "${value}". Use letters, digits, ".", "_", "-" and "/" (not starting with "-").`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export function assertCloudflareProject(value) {
|
|
13
|
+
if (!/^[a-z0-9][a-z0-9-]{0,57}$/.test(value))
|
|
14
|
+
throw new VageError(`Invalid --project "${value}". Use lowercase letters, digits and "-".`);
|
|
15
|
+
return value;
|
|
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
|
+
}
|
|
23
|
+
/** https://, ssh://, git@host:path, or a local path. Refuses options and git's command-running transports. */
|
|
24
|
+
export function assertGitRemote(value) {
|
|
25
|
+
if (value.startsWith("-") || /^(ext|fd)::/i.test(value) || /[\s\u0000-\u001f]/.test(value)) {
|
|
26
|
+
throw new VageError(`Invalid --remote "${value}". Use an https://, ssh:// or git@host:owner/repo.git URL.`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
/** npm dependency spec for new projects: a version/range, "latest", or a local file: tarball. */
|
|
31
|
+
export function assertVageSpec(value) {
|
|
32
|
+
if (!/^(latest|[~^]?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?|file:[^\s]+)$/.test(value)) {
|
|
33
|
+
throw new VageError(`Invalid --vage-spec "${value}". Use a version like ^0.1.0, "latest", or file:<path-to-tarball>.`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DeployStatus, Runner } from "./github-pages.js";
|
|
2
|
+
/** Pinned so deploys are reproducible; checked 2026-10-23. */
|
|
3
|
+
export declare const WRANGLER = "wrangler@4.152.0";
|
|
4
|
+
/**
|
|
5
|
+
* Uploads dist/ with `wrangler pages deploy`. Auth comes from the environment
|
|
6
|
+
* (CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID) or a prior `wrangler login`.
|
|
7
|
+
* Reports success only when wrangler succeeds AND the deployment URL serves the site.
|
|
8
|
+
*/
|
|
9
|
+
export declare function deployCloudflarePages(root: string, options: {
|
|
10
|
+
project: string;
|
|
11
|
+
branch: string;
|
|
12
|
+
commit?: string;
|
|
13
|
+
run?: Runner;
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
sleep?: (ms: number) => Promise<void>;
|
|
16
|
+
env?: Record<string, string | undefined>;
|
|
17
|
+
}): Promise<DeployStatus & {
|
|
18
|
+
deploymentUrl?: string;
|
|
19
|
+
branch: string;
|
|
20
|
+
}>;
|