vagejs 0.3.5 → 0.5.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 +32 -6
- package/dist/audit/budgets.js +21 -1
- package/dist/checks/check.js +63 -5
- package/dist/cli/index.js +77 -5
- package/dist/cli/info.d.ts +1 -0
- package/dist/cli/info.js +7 -6
- package/dist/cli/new.d.ts +27 -0
- package/dist/cli/new.js +97 -0
- package/dist/content/markdown.d.ts +1 -0
- package/dist/content/markdown.js +4 -0
- package/dist/content/optimize.d.ts +8 -4
- package/dist/content/optimize.js +35 -22
- package/dist/content/sanitize.d.ts +12 -9
- package/dist/content/sanitize.js +86 -10
- package/dist/core/build.d.ts +5 -0
- package/dist/core/build.js +1 -0
- package/dist/core/config.d.ts +8 -0
- package/dist/core/config.js +24 -4
- package/dist/core/dates.d.ts +6 -0
- package/dist/core/dates.js +14 -0
- package/dist/core/pages.d.ts +15 -1
- package/dist/core/pages.js +27 -5
- package/dist/core/render.d.ts +2 -0
- package/dist/core/render.js +4 -4
- package/dist/core/site.d.ts +9 -0
- package/dist/core/site.js +49 -7
- package/dist/deploy/netlify.d.ts +22 -0
- package/dist/deploy/netlify.js +48 -0
- package/dist/deploy/redirects.d.ts +5 -0
- package/dist/deploy/redirects.js +8 -0
- package/dist/dev/guard.js +1 -1
- package/dist/dev/server.js +15 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp/server.d.ts +27 -0
- package/dist/mcp/server.js +81 -0
- package/package.json +2 -1
- package/templates/starter/AGENTS.md +26 -10
- package/templates/starter/package.json +1 -1
package/dist/core/site.js
CHANGED
|
@@ -7,7 +7,7 @@ import { copySource, htmlToMarkdown, markdownCopy, markdownCopyPath } from "../d
|
|
|
7
7
|
import { robotsTxt } from "../discovery/robots.js";
|
|
8
8
|
import { SITEMAP_URL_LIMIT, sitemapXml } from "../discovery/sitemap.js";
|
|
9
9
|
import { copyCanonicalsFit, headersFile } from "../deploy/headers.js";
|
|
10
|
-
import { cloudflareRedirects } from "../deploy/redirects.js";
|
|
10
|
+
import { cloudflareRedirects, netlifyRedirects } from "../deploy/redirects.js";
|
|
11
11
|
import { findSecrets } from "../deploy/secrets.js";
|
|
12
12
|
import { rulePack } from "../guidelines/pack.js";
|
|
13
13
|
import { absoluteUrl } from "../seo/urls.js";
|
|
@@ -17,7 +17,8 @@ import { escapeHtml } from "./html.js";
|
|
|
17
17
|
import { renderPage } from "./render.js";
|
|
18
18
|
import { parseFrontmatter } from "../content/markdown.js";
|
|
19
19
|
import { sharpResize } from "../content/optimize.js";
|
|
20
|
-
import {
|
|
20
|
+
import { scheduledFor } from "./dates.js";
|
|
21
|
+
import { collection, collectPages, tagList } from "./pages.js";
|
|
21
22
|
import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "./routes.js";
|
|
22
23
|
/** Renders every page and derives discovery files from the same data, without writing anything. */
|
|
23
24
|
export async function renderSite(root, load, options = {}) {
|
|
@@ -31,7 +32,7 @@ export async function renderSite(root, load, options = {}) {
|
|
|
31
32
|
throw new VageError(`This project pins rule pack ${pinned}, but the installed vagejs ships ${rulePack.version}. ` +
|
|
32
33
|
`Review what changed with "vage guidelines", then update guidelines.rulePack in vage.config.ts.`);
|
|
33
34
|
}
|
|
34
|
-
const model = { site, pageErrors: [], pages: [], skippedDrafts: [], draftRoutes: [], redirects: [], files: new Map(), warnings: [] };
|
|
35
|
+
const model = { site, pageErrors: [], pages: [], skippedDrafts: [], draftRoutes: [], skippedScheduled: [], redirects: [], files: new Map(), warnings: [] };
|
|
35
36
|
const collect = options.collectErrors === true;
|
|
36
37
|
const attempt = async (file, route, work) => {
|
|
37
38
|
try {
|
|
@@ -51,16 +52,37 @@ export async function renderSite(root, load, options = {}) {
|
|
|
51
52
|
for (const entry of discoverRoutes(pagesDir)) {
|
|
52
53
|
const source = path.relative(root, entry.file);
|
|
53
54
|
await attempt(entry.file, entry.route, async () => {
|
|
54
|
-
// Drafts are skipped before rendering, so their fields can't fail a build.
|
|
55
|
-
|
|
55
|
+
// Drafts and scheduled pages are skipped before rendering, so their fields can't fail a build.
|
|
56
|
+
const { frontmatter } = parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file);
|
|
57
|
+
if (frontmatter.draft) {
|
|
56
58
|
model.skippedDrafts.push(source);
|
|
57
59
|
model.draftRoutes.push(entry.route);
|
|
58
60
|
return;
|
|
59
61
|
}
|
|
62
|
+
const date = scheduledFor(frontmatter.datePublished);
|
|
63
|
+
if (date) {
|
|
64
|
+
model.skippedScheduled.push({ source, route: entry.route, date });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
60
67
|
const page = await renderPage({ root, file: entry.file, route: entry.route, site, load, analytics: true, pages, resize });
|
|
61
68
|
model.pages.push({ ...page, route: entry.route, source, outFile: entry.outFile });
|
|
62
69
|
});
|
|
63
70
|
}
|
|
71
|
+
if (site.tags.pages) {
|
|
72
|
+
// Only published pages can clash: a draft or scheduled page at a tag address isn't in the build.
|
|
73
|
+
const taken = new Map(model.pages.map((p) => [p.route, p.source]));
|
|
74
|
+
for (const tag of tagList(pages)) {
|
|
75
|
+
const file = path.join(pagesDir, "tags", `${tag.slug}.md`);
|
|
76
|
+
const source = `(tag "${tag.name}")`;
|
|
77
|
+
await attempt(file, tag.route, async () => {
|
|
78
|
+
if (taken.has(tag.route)) {
|
|
79
|
+
throw new VageError(`${taken.get(tag.route)} and the tag "${tag.name}" both make ${tag.route}. Rename one, or set tags.pages: false in vage.config.ts.`);
|
|
80
|
+
}
|
|
81
|
+
const page = await renderPage({ root, file, route: tag.route, site, load, analytics: true, pages, resize, source: tagPageSource(site, tag.name, collection(pages, { tag: tag.name, sort: "date" })) });
|
|
82
|
+
model.pages.push({ ...page, route: tag.route, source, outFile: `${tag.route.slice(1)}index.html` });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
64
86
|
const notFound = findNotFoundPage(pagesDir);
|
|
65
87
|
if (notFound) {
|
|
66
88
|
await attempt(notFound, undefined, async () => {
|
|
@@ -79,6 +101,23 @@ export async function renderSite(root, load, options = {}) {
|
|
|
79
101
|
addDiscoveryFiles(root, model, markdown);
|
|
80
102
|
return model;
|
|
81
103
|
}
|
|
104
|
+
/** A tag page's Markdown: a heading and the tagged pages, newest first. Written as HTML so tag and page names can't become Markdown. */
|
|
105
|
+
export function tagPageSource(site, name, tagged) {
|
|
106
|
+
const items = tagged.map((p) => `<li><a href="${escapeHtml(p.route)}">${escapeHtml(p.title)}</a>${p.description ? `: ${escapeHtml(p.description)}` : ""}</li>`);
|
|
107
|
+
const description = `Pages${site.title ? ` on ${site.title}` : ""} tagged ${name}.`;
|
|
108
|
+
return [
|
|
109
|
+
"---",
|
|
110
|
+
`title: ${JSON.stringify(`Tagged: ${name}`)}`,
|
|
111
|
+
`description: ${JSON.stringify(description)}`,
|
|
112
|
+
...(site.tags.layout ? [`layout: ${site.tags.layout}`] : []),
|
|
113
|
+
"---",
|
|
114
|
+
"",
|
|
115
|
+
`<h1>Tagged: ${escapeHtml(name)}</h1>`,
|
|
116
|
+
"",
|
|
117
|
+
`<ul>\n${items.join("\n")}\n</ul>`,
|
|
118
|
+
"",
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
82
121
|
/** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
|
|
83
122
|
export function listedPages(model) {
|
|
84
123
|
return model.pages.filter((p) => !p.meta.noindex && p.meta.canonical === absoluteUrl(model.site.site, p.route));
|
|
@@ -134,11 +173,14 @@ function addDiscoveryFiles(root, model, markdown) {
|
|
|
134
173
|
const indexNow = site.integrations.indexNow;
|
|
135
174
|
if (indexNow)
|
|
136
175
|
generate(`${indexNow.key}.txt`, () => `${indexNow.key}\n`);
|
|
137
|
-
// Cloudflare Pages
|
|
176
|
+
// Cloudflare Pages, Workers static assets and Netlify all read _redirects and _headers.
|
|
177
|
+
const netlify = site.deploy.target === "netlify";
|
|
138
178
|
const cloudflare = site.deploy.target === "cloudflare-pages" || site.deploy.target === "cloudflare-workers";
|
|
139
179
|
if (cloudflare && model.redirects.length)
|
|
140
180
|
generate("_redirects", () => cloudflareRedirects(model.redirects));
|
|
141
|
-
if (
|
|
181
|
+
if (netlify && model.redirects.length)
|
|
182
|
+
generate("_redirects", () => netlifyRedirects(model.redirects));
|
|
183
|
+
if ((cloudflare || netlify) && site.security.headers) {
|
|
142
184
|
const html = [...model.pages, ...(model.notFound ? [model.notFound] : []), ...model.redirects].map((p) => p.html);
|
|
143
185
|
// Each Markdown copy names its HTML page as canonical, so search engines don't index both.
|
|
144
186
|
const copies = site.seo.markdownCopies && site.site ? listed.map((p) => ({ path: `/${markdownCopyPath(p.route)}`, canonical: p.meta.canonical })).filter((c) => /^\/[A-Za-z0-9._~\/-]+$/.test(c.path) && /^[^\s<>]+$/.test(c.canonical)) : [];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DeployStatus, Runner } from "./github-pages.js";
|
|
2
|
+
/** Pinned so deploys are reproducible; checked 2026-10-25. */
|
|
3
|
+
export declare const NETLIFY_CLI = "netlify-cli@27.12.1";
|
|
4
|
+
export interface NetlifyDeployment extends DeployStatus {
|
|
5
|
+
site: string;
|
|
6
|
+
deployId?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Uploads dist/ to a Netlify site's production deploy with netlify-cli (no Netlify build:
|
|
10
|
+
* vagejs already built it). Auth comes from NETLIFY_AUTH_TOKEN or a prior `netlify login`,
|
|
11
|
+
* never a command-line flag. The build wrote Netlify's _headers and _redirects (forced
|
|
12
|
+
* 301s, so they win over the HTML forwarding pages). Success needs netlify-cli to
|
|
13
|
+
* succeed AND the live site to answer 200: `siteUrl` when set, else the site's Netlify URL.
|
|
14
|
+
*/
|
|
15
|
+
export declare function deployNetlify(root: string, options: {
|
|
16
|
+
site: string;
|
|
17
|
+
siteUrl?: string;
|
|
18
|
+
run?: Runner;
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
sleep?: (ms: number) => Promise<void>;
|
|
21
|
+
env?: Record<string, string | undefined>;
|
|
22
|
+
}): Promise<NetlifyDeployment>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { redact } from "../integrations/http.js";
|
|
2
|
+
import { runCommand } from "./github-pages.js";
|
|
3
|
+
/** Pinned so deploys are reproducible; checked 2026-10-25. */
|
|
4
|
+
export const NETLIFY_CLI = "netlify-cli@27.12.1";
|
|
5
|
+
/**
|
|
6
|
+
* Uploads dist/ to a Netlify site's production deploy with netlify-cli (no Netlify build:
|
|
7
|
+
* vagejs already built it). Auth comes from NETLIFY_AUTH_TOKEN or a prior `netlify login`,
|
|
8
|
+
* never a command-line flag. The build wrote Netlify's _headers and _redirects (forced
|
|
9
|
+
* 301s, so they win over the HTML forwarding pages). Success needs netlify-cli to
|
|
10
|
+
* succeed AND the live site to answer 200: `siteUrl` when set, else the site's Netlify URL.
|
|
11
|
+
*/
|
|
12
|
+
export async function deployNetlify(root, options) {
|
|
13
|
+
const run = options.run ?? runCommand;
|
|
14
|
+
const doFetch = options.fetch ?? fetch;
|
|
15
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
16
|
+
const secrets = [(options.env ?? process.env).NETLIFY_AUTH_TOKEN];
|
|
17
|
+
const { site } = options;
|
|
18
|
+
const res = await run("npx", ["--yes", NETLIFY_CLI, "deploy", "--dir", "dist", "--prod", "--no-build", "--site", site, "--json"], root);
|
|
19
|
+
const output = redact(`${res.stdout}\n${res.stderr}`, secrets);
|
|
20
|
+
if (res.code !== 0) {
|
|
21
|
+
const auth = /auth|log ?in|token|unauthori[sz]ed|401/i.test(output) ? " Set NETLIFY_AUTH_TOKEN, or run `npx netlify-cli login`." : "";
|
|
22
|
+
const missing = /not found|404|no (?:project|site)/i.test(output) ? ` Check that the Netlify site "${site}" exists (deploy.netlifySite is its name or ID).` : "";
|
|
23
|
+
return { status: "failure", site, reason: `netlify-cli failed (exit ${res.code}).${auth}${missing}\n${tail(output)}` };
|
|
24
|
+
}
|
|
25
|
+
let data = {};
|
|
26
|
+
try {
|
|
27
|
+
data = JSON.parse(res.stdout.slice(res.stdout.indexOf("{")));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Older or newer netlify-cli output; the live check below still decides.
|
|
31
|
+
}
|
|
32
|
+
const deployId = typeof data.deploy_id === "string" ? data.deploy_id : undefined;
|
|
33
|
+
const netlifyUrl = typeof data.url === "string" && /^https:\/\//.test(data.url) ? data.url : undefined;
|
|
34
|
+
const pageUrl = options.siteUrl ?? netlifyUrl;
|
|
35
|
+
if (!pageUrl)
|
|
36
|
+
return { status: "unconfirmed", site, deployId, reason: `Uploaded, but there is no URL to check: set \`site\` in vage.config.ts.\n${tail(output)}` };
|
|
37
|
+
for (let i = 0; i < 5; i++) {
|
|
38
|
+
const page = await doFetch(pageUrl, { signal: AbortSignal.timeout(20_000) }).catch(() => undefined);
|
|
39
|
+
if (page?.ok)
|
|
40
|
+
return { status: "success", site, deployId, pageUrl };
|
|
41
|
+
await sleep(3000);
|
|
42
|
+
}
|
|
43
|
+
const own = netlifyUrl && netlifyUrl !== pageUrl ? ` The site itself is at ${netlifyUrl}. If ${pageUrl} is your own domain, add it in Netlify under Domain management.` : "";
|
|
44
|
+
return { status: "unconfirmed", site, deployId, pageUrl, reason: `Deployed, but ${pageUrl} did not respond with 200 yet. Check it in a minute.${own}` };
|
|
45
|
+
}
|
|
46
|
+
function tail(text) {
|
|
47
|
+
return text.trim().split("\n").slice(-8).join("\n");
|
|
48
|
+
}
|
|
@@ -5,3 +5,8 @@ import type { SiteRedirect } from "../core/site.js";
|
|
|
5
5
|
* Both "/old" and "/old/" are listed so either form redirects.
|
|
6
6
|
*/
|
|
7
7
|
export declare function cloudflareRedirects(redirects: SiteRedirect[]): string;
|
|
8
|
+
/**
|
|
9
|
+
* Netlify `_redirects`: the same rules, forced ("301!"). Netlify serves an existing file
|
|
10
|
+
* before a redirect, and vagejs writes an HTML forwarding page at each old address.
|
|
11
|
+
*/
|
|
12
|
+
export declare function netlifyRedirects(redirects: SiteRedirect[]): string;
|
package/dist/deploy/redirects.js
CHANGED
|
@@ -16,3 +16,11 @@ export function cloudflareRedirects(redirects) {
|
|
|
16
16
|
throw new VageError(`_redirects would have ${lines.length} rules; Cloudflare Pages allows ${maxStaticRules}.`);
|
|
17
17
|
return `# Generated by vagejs from redirects in vage.config.ts\n${lines.join("\n")}\n`;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Netlify `_redirects`: the same rules, forced ("301!"). Netlify serves an existing file
|
|
21
|
+
* before a redirect, and vagejs writes an HTML forwarding page at each old address.
|
|
22
|
+
*/
|
|
23
|
+
export function netlifyRedirects(redirects) {
|
|
24
|
+
const lines = redirects.flatMap((r) => (r.from === "/" ? ["/"] : [r.from, r.from.replace(/\/$/, "")]).map((from) => `${from} ${r.to} 301!`));
|
|
25
|
+
return `# Generated by vagejs from redirects in vage.config.ts\n${lines.join("\n")}\n`;
|
|
26
|
+
}
|
package/dist/dev/guard.js
CHANGED
|
@@ -46,7 +46,7 @@ export function allowed(url, publicDir, allowedFiles) {
|
|
|
46
46
|
if (allowedFiles.has(pathname))
|
|
47
47
|
return true;
|
|
48
48
|
// Optimized images (images.optimize), served from the cache by the dev server.
|
|
49
|
-
if (/^\/_vage\/img\/[\w-]+\.webp$/.test(pathname))
|
|
49
|
+
if (/^\/_vage\/img\/[\w-]+\.(webp|avif)$/.test(pathname))
|
|
50
50
|
return true;
|
|
51
51
|
// Generated files (seo.feeds, seo.markdownCopies) never exist on disk in dev. If a real file
|
|
52
52
|
// sits at that path, it's a project source (e.g. /src/pages/about.md): Vite would serve it, so refuse.
|
package/dist/dev/server.js
CHANGED
|
@@ -7,8 +7,8 @@ import { escapeHtml } from "../core/html.js";
|
|
|
7
7
|
import { baseViteConfig, viteLoader } from "../core/loader.js";
|
|
8
8
|
import { renderPage } from "../core/render.js";
|
|
9
9
|
import { cacheDir } from "../content/optimize.js";
|
|
10
|
-
import { collectPages } from "../core/pages.js";
|
|
11
|
-
import { renderSite } from "../core/site.js";
|
|
10
|
+
import { collection, collectPages, tagList } from "../core/pages.js";
|
|
11
|
+
import { renderSite, tagPageSource } from "../core/site.js";
|
|
12
12
|
import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "../core/routes.js";
|
|
13
13
|
import { devAccessGuard } from "./guard.js";
|
|
14
14
|
import { listenOrExplain } from "./listen.js";
|
|
@@ -48,12 +48,12 @@ export async function startDevServer(root, options = {}) {
|
|
|
48
48
|
const urlPath = req.url ?? "/";
|
|
49
49
|
const pathname = urlPath.split(/[?#]/)[0];
|
|
50
50
|
try {
|
|
51
|
-
const optimized = /^\/_vage\/img\/([\w-]+\.webp)$/.exec(pathname);
|
|
51
|
+
const optimized = /^\/_vage\/img\/([\w-]+\.(webp|avif))$/.exec(pathname);
|
|
52
52
|
if (optimized) {
|
|
53
53
|
const file = path.join(cacheDir(root), optimized[1]);
|
|
54
54
|
if (!fs.existsSync(file))
|
|
55
55
|
return send(res, 404, plainPage("404 — Not found", `No optimized image at ${pathname}`));
|
|
56
|
-
res.setHeader("Content-Type",
|
|
56
|
+
res.setHeader("Content-Type", `image/${optimized[2]}`);
|
|
57
57
|
return res.end(fs.readFileSync(file));
|
|
58
58
|
}
|
|
59
59
|
const isFeed = /^\/(?:[\w.-]+\/)*feed\.xml$/.test(pathname) && !pathname.includes("..");
|
|
@@ -91,6 +91,17 @@ export async function startDevServer(root, options = {}) {
|
|
|
91
91
|
if (entry && !pathname.endsWith("/") && !pathname.endsWith(".html")) {
|
|
92
92
|
return redirect(res, entry.route + urlPath.slice(pathname.length));
|
|
93
93
|
}
|
|
94
|
+
if (!entry && site.tags.pages && route.startsWith("/tags/")) {
|
|
95
|
+
const pages = await collectPages(root, site, load, { skipInvalid: true });
|
|
96
|
+
const tag = tagList(pages).find((t) => t.route === route);
|
|
97
|
+
if (tag) {
|
|
98
|
+
if (!pathname.endsWith("/"))
|
|
99
|
+
return redirect(res, tag.route + urlPath.slice(pathname.length));
|
|
100
|
+
const source = tagPageSource(site, tag.name, collection(pages, { tag: tag.name, sort: "date" }));
|
|
101
|
+
const page = await renderPage({ root, file: path.join(pagesDir, "tags", `${tag.slug}.md`), route, site, load, pages, source });
|
|
102
|
+
return send(res, 200, await server.transformIndexHtml(urlPath, page.html));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
94
105
|
const notFound = findNotFoundPage(pagesDir);
|
|
95
106
|
if (!entry && !notFound) {
|
|
96
107
|
return send(res, 404, await server.transformIndexHtml(urlPath, plainPage("404 — Not found", `No page for ${route}`)));
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { escapeHtml, html, raw, type SafeHtml } from "./core/html.js";
|
|
|
4
4
|
export { z } from "zod";
|
|
5
5
|
export type { Layout, LayoutProps } from "./core/render.js";
|
|
6
6
|
export type { Frontmatter } from "./content/markdown.js";
|
|
7
|
-
export { breadcrumbs, collection, flatten, neighbors, tree, type CollectionOptions, type PageSummary, type TreeNode } from "./core/pages.js";
|
|
7
|
+
export { breadcrumbs, collection, flatten, neighbors, tagList, tagSlug, tree, type CollectionOptions, type PageSummary, type TagSummary, type TreeNode } from "./core/pages.js";
|
|
8
8
|
export { build, type BuildResult } from "./core/build.js";
|
|
9
9
|
export { startDevServer } from "./dev/server.js";
|
|
10
10
|
export { startPreviewServer } from "./dev/preview.js";
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ export { defineConfig } from "./core/config.js";
|
|
|
2
2
|
export { escapeHtml, html, raw } from "./core/html.js";
|
|
3
3
|
/** Zod, for layout field schemas: `export const frontmatter = z.object({ ... })`. Same instance vagejs validates with. */
|
|
4
4
|
export { z } from "zod";
|
|
5
|
-
export { breadcrumbs, collection, flatten, neighbors, tree } from "./core/pages.js";
|
|
5
|
+
export { breadcrumbs, collection, flatten, neighbors, tagList, tagSlug, tree } from "./core/pages.js";
|
|
6
6
|
export { build } from "./core/build.js";
|
|
7
7
|
export { startDevServer } from "./dev/server.js";
|
|
8
8
|
export { startPreviewServer } from "./dev/preview.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Published pages, drafts and scheduled pages, without writing anything. */
|
|
2
|
+
export declare function listPages(root: string): Promise<{
|
|
3
|
+
pages: {
|
|
4
|
+
route: string;
|
|
5
|
+
source: string;
|
|
6
|
+
title: string;
|
|
7
|
+
description: string | undefined;
|
|
8
|
+
layout: string;
|
|
9
|
+
noindex: boolean;
|
|
10
|
+
}[];
|
|
11
|
+
drafts: string[];
|
|
12
|
+
scheduled: {
|
|
13
|
+
source: string;
|
|
14
|
+
date: string;
|
|
15
|
+
}[];
|
|
16
|
+
errors: import("../core/site.js").PageError[];
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* `vage mcp`: an MCP server over stdio for the project at `root`. Every tool is
|
|
20
|
+
* read-only; approving links and deploying stay with people, through the CLI.
|
|
21
|
+
* stdout carries the protocol, so anything else that prints goes to stderr.
|
|
22
|
+
*/
|
|
23
|
+
export declare function startMcpServer(root: string, options: {
|
|
24
|
+
version: string;
|
|
25
|
+
info: () => unknown;
|
|
26
|
+
transport?: unknown;
|
|
27
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { check } from "../checks/check.js";
|
|
5
|
+
import { linkStatus } from "../checks/links.js";
|
|
6
|
+
import { VageError } from "../core/errors.js";
|
|
7
|
+
import { createBuildLoader } from "../core/loader.js";
|
|
8
|
+
import { renderSite } from "../core/site.js";
|
|
9
|
+
/** The MCP SDK is optional: only people who run `vage mcp` install it. */
|
|
10
|
+
const SDK = "@modelcontextprotocol/sdk";
|
|
11
|
+
const INSTALL = `vage mcp needs the "${SDK}" package. Install it in your project with: npm install --save-dev ${SDK}`;
|
|
12
|
+
/** Loads the SDK from the project first, then from wherever vagejs itself is installed. */
|
|
13
|
+
async function loadSdk(root) {
|
|
14
|
+
for (const from of [path.join(root, "package.json"), import.meta.url]) {
|
|
15
|
+
try {
|
|
16
|
+
const req = createRequire(from);
|
|
17
|
+
const mcp = await import(pathToFileURL(req.resolve(`${SDK}/server/mcp.js`)).href);
|
|
18
|
+
const stdio = await import(pathToFileURL(req.resolve(`${SDK}/server/stdio.js`)).href);
|
|
19
|
+
return { McpServer: mcp.McpServer, StdioServerTransport: stdio.StdioServerTransport };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// try the next location
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
throw new VageError(INSTALL);
|
|
26
|
+
}
|
|
27
|
+
/** Published pages, drafts and scheduled pages, without writing anything. */
|
|
28
|
+
export async function listPages(root) {
|
|
29
|
+
const loader = await createBuildLoader(root);
|
|
30
|
+
try {
|
|
31
|
+
const model = await renderSite(root, loader.load, { collectErrors: true });
|
|
32
|
+
return {
|
|
33
|
+
pages: model.pages.map((p) => ({
|
|
34
|
+
route: p.route,
|
|
35
|
+
source: p.source,
|
|
36
|
+
title: p.frontmatter.title,
|
|
37
|
+
description: p.frontmatter.description,
|
|
38
|
+
layout: p.frontmatter.layout ?? "default",
|
|
39
|
+
noindex: Boolean(p.meta.noindex),
|
|
40
|
+
})),
|
|
41
|
+
drafts: model.skippedDrafts,
|
|
42
|
+
scheduled: model.skippedScheduled.map(({ source, date }) => ({ source, date })),
|
|
43
|
+
errors: model.pageErrors,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await loader.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
51
|
+
const text = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
52
|
+
/** Runs a tool; a failure (a broken layout, an invalid config) becomes a readable error result, without terminal colors. */
|
|
53
|
+
const tool = (fn) => async () => {
|
|
54
|
+
try {
|
|
55
|
+
return text(await fn());
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
// eslint-disable-next-line no-control-regex
|
|
59
|
+
const message = String(err?.message ?? err).replace(/\u001b\[[0-9;]*m/g, "");
|
|
60
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* `vage mcp`: an MCP server over stdio for the project at `root`. Every tool is
|
|
65
|
+
* read-only; approving links and deploying stay with people, through the CLI.
|
|
66
|
+
* stdout carries the protocol, so anything else that prints goes to stderr.
|
|
67
|
+
*/
|
|
68
|
+
export async function startMcpServer(root, options) {
|
|
69
|
+
root = path.resolve(root);
|
|
70
|
+
const sdk = await loadSdk(root);
|
|
71
|
+
if (!options.transport)
|
|
72
|
+
console.log = console.info = (...args) => console.error(...args);
|
|
73
|
+
const server = new sdk.McpServer({ name: "vagejs", version: options.version }, {
|
|
74
|
+
instructions: "Tools for a vagejs website project. Use vage_info for commands, settings and page fields; vage_check after every change (fix every error it reports; each finding names the file); vage_pages to see what exists; vage_links for outbound domains. Never approve link domains or deploy without the site owner: those stay CLI commands (npx vage links approve, npx vage deploy).",
|
|
75
|
+
});
|
|
76
|
+
server.registerTool("vage_info", { title: "Describe vagejs", description: "Commands, configuration options and page frontmatter fields, with their JSON Schemas (the same as `vage info --json`).", annotations: READ_ONLY }, tool(async () => options.info()));
|
|
77
|
+
server.registerTool("vage_check", { title: "Check the site", description: "Checks every page for broken links, missing titles and descriptions, structured data, accessibility basics and unapproved outbound links (the same as `vage check --json`). Writes nothing.", annotations: READ_ONLY }, tool(() => check(root)));
|
|
78
|
+
server.registerTool("vage_pages", { title: "List pages", description: "Every published page (route, source file, title, description, layout, noindex), the drafts and scheduled pages that are skipped, and pages that fail to render.", annotations: READ_ONLY }, tool(() => listPages(root)));
|
|
79
|
+
server.registerTool("vage_links", { title: "List outbound domains", description: "External domains the site links to, which pages link there, and whether each is approved in links.lock.json. Only the site owner approves new domains.", annotations: READ_ONLY }, tool(() => linkStatus(root)));
|
|
80
|
+
await server.connect(options.transport ?? new sdk.StdioServerTransport());
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vagejs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A free static site generator for AI website development: Markdown pages with built-in SEO, llms.txt, checks, spam protection and safe deploys.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"static-site-generator",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@anthropic-ai/sdk": "^1.1.0",
|
|
60
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
60
61
|
"@types/jsdom": "^28.0.3",
|
|
61
62
|
"@types/node": "^26.6.5",
|
|
62
63
|
"@types/turndown": "^5.0.6",
|
|
@@ -16,21 +16,35 @@ This website uses **vagejs** (pre-release). Only the features listed here exist
|
|
|
16
16
|
Add `--json` to `npx vage build`, `check` or `audit` for machine-readable output (`{ ok: false, error }` on failure).
|
|
17
17
|
Run `npm run check` after every change and fix all errors before finishing.
|
|
18
18
|
|
|
19
|
+
## Windows
|
|
20
|
+
|
|
21
|
+
vagejs is tested on Windows on every change.
|
|
22
|
+
|
|
23
|
+
- Node.js **22.12 or newer** is needed. Check with `node --version` in a new PowerShell or Command Prompt window.
|
|
24
|
+
- The `npm` and `npx` commands in this file work in PowerShell and Command Prompt. In Command Prompt, leave out any `# …` notes after a command: it doesn't treat `#` as a comment.
|
|
25
|
+
- Windows usually saves files with **CRLF** line endings. vagejs handles them; don't convert line endings.
|
|
26
|
+
- If a browser shows **HTTP 431** on `localhost`, open the direct `[::1]` or `127.0.0.1` address that `npm start` and `npm run dev` print.
|
|
27
|
+
|
|
19
28
|
## Machine-readable reference
|
|
20
29
|
|
|
21
30
|
`npx vage info --json` prints every command and option plus JSON Schemas for `vage.config.ts` and page frontmatter. Prefer it over guessing.
|
|
22
31
|
|
|
32
|
+
**MCP server (optional):** `npx vage mcp` runs an MCP server over stdio with four read-only tools: `vage_info`, `vage_check`, `vage_pages` and `vage_links`. It needs `npm install --save-dev @modelcontextprotocol/sdk` in the project. For Claude Code, run in the project folder: `claude mcp add --transport stdio --scope project vagejs -- npx -p vagejs vage mcp` (saved in `.mcp.json`). Other assistants: `{ "mcpServers": { "vagejs": { "command": "npx", "args": ["-p", "vagejs", "vage", "mcp", "--root", "/absolute/path/to/the/site"] } } }`. `-p vagejs` makes npx run the vagejs installed in the project, and never a different package that happens to be called `vage`. Approving link domains and deploying are not MCP tools; they stay CLI commands the site owner approves. Layouts must not write to `process.stdout` directly: in MCP mode stdout carries the protocol (vagejs moves `console.log` to stderr).
|
|
33
|
+
|
|
23
34
|
## Recipes
|
|
24
35
|
|
|
25
|
-
- **Add a page:**
|
|
26
|
-
- **Add a blog post:** `src/pages/blog/<slug>.md
|
|
36
|
+
- **Add a page:** `npx vage new page <slug>` (folders allowed: `docs/setup`; options `--title`, `--description`) creates `src/pages/<slug>.md` as a draft with valid frontmatter, or create the file yourself with `title` and `description`. Link to it as `/<slug>/` (trailing slash). Remove `draft: true` when it's ready, then run `npm run check`.
|
|
37
|
+
- **Add a blog post:** `npx vage new post "<Title>"` creates `src/pages/blog/<slug>.md` as a draft with `type: article` and today's `datePublished` (`--date`, `--under <folder>`, and `--author` only with a real name from the owner). It appears on `/blog/` automatically (`src/layouts/blog.ts`) once `draft: true` is removed.
|
|
27
38
|
- **Add a layout:** `src/layouts/<name>.ts`, then `layout: <name>` in frontmatter. Keep `<html lang>`, one `<title>` and `</head>`.
|
|
28
39
|
- **List pages (blog index, "latest posts", a sidebar):** in a layout, `collection(pages, { under: "/blog/", sort: "date" })` from `vagejs`. Never keep a hand-written list of pages.
|
|
29
40
|
- **Give a layout its own frontmatter fields:** `export const frontmatter = z.object({ ... })` in the layout (`import { z } from "vagejs"`). Pages using it are then checked strictly: unknown or mistyped fields fail the build.
|
|
30
|
-
- **Add an image:** put it in `public/images/` and write ``. Width and height are filled in from the file; write real alt text. For resized WebP copies, the owner can turn on `images: { optimize: true }` (needs `npm install --save-dev sharp`)
|
|
41
|
+
- **Add an image:** put it in `public/images/` and write ``. Width and height are filled in from the file; write real alt text. For resized WebP copies, the owner can turn on `images: { optimize: true }` (needs `npm install --save-dev sharp`), and `avif: true` for AVIF copies offered first through `<picture>`. Only images in page content are optimized, not images a layout writes.
|
|
31
42
|
- **Order a menu or sidebar:** set `order:` (lower first) and optionally a short `navTitle:` in each page's frontmatter (shown as `p.label`), then build the menu in a layout with `tree(pages, "/docs/")`. For previous/next links in the same order use `neighbors(flatten(tree(pages, "/docs/")), page.route)`. For a flat section, `collection(pages, { under: "/docs/", sort: "order", includeIndex: true })` works too. `breadcrumbs(pages, page.route)` gives the trail from the home page.
|
|
32
43
|
- **Add an RSS feed for the blog:** `seo: { feeds: [{ under: "/blog/" }] }` in `vage.config.ts` writes `/blog/feed.xml`, newest `datePublished` first (undated posts last, without a date).
|
|
33
44
|
- **Help AI tools read the site:** `seo: { markdownCopies: true }` publishes a Markdown copy of every listed page (`/docs/cli/` becomes `/docs/cli.md`), converted from the page's `<main>`, so it contains exactly what the page shows (including content a layout builds from frontmatter), and `llms.txt` links to them. In layouts, add `data-vage-copy="skip"` to repeated extras such as share links or a call to action to leave them out of the copy. On Cloudflare, each copy gets a canonical `Link` header pointing to its page. `seo: { lastmod: "git" }` dates the sitemap from git history (needs full history in CI: `fetch-depth: 0`).
|
|
45
|
+
- **Link to a section:** every `##` and `###` heading gets an id from its text (`## Quick start` becomes `id="quick-start"`), so link to `/docs/setup/#quick-start`. `npm run check` warns when a `#section` matches no id on the target page (`link-fragment`). Write `<h2 id="...">` in Markdown for an id of your own. Ids are only kept unique within the page content, so avoid headings that repeat an id the layout uses (such as `main`); `markdown: { headingIds: false }` turns automatic ids off.
|
|
46
|
+
- **Schedule a post:** give it a future `datePublished` (and no `draft: true`). It stays out of builds, lists, feeds and the sitemap until a build on or after that day; `npm run build` lists it as `(scheduled for <date>, skipped)`. A date goes live on that day in the build machine's time zone (UTC on most CI); use a datetime such as `2026-11-01T09:00:00+05:30` for an exact time. Nothing rebuilds the site by itself: the owner needs a deploy on that day (for example a scheduled GitHub Actions workflow). `vage indexnow --changed-since` skips drafts and scheduled pages; once the page is live, submit it with `--urls`.
|
|
47
|
+
- **Tag pages:** `tags: [SEO, AI]` in frontmatter. Each tag gets a page at `/tags/<tag>/` listing its pages, newest first (`tags: { layout: "<name>" }` picks its layout, `tags: { pages: false }` turns them off). In layouts, `collection(pages, { tag: "SEO" })` picks tagged pages and `tagList(pages)` lists every tag with its `route` and `count`. Tags are matched by their address, so "SEO" and "seo" are the same tag.
|
|
34
48
|
- **Rename a page:** move the file, then add `redirects: { "/old-slug/": "/new-slug/" }` to `vage.config.ts`.
|
|
35
49
|
- **Hide a page from search:** `noindex: true` (it stays published). To unpublish, use `draft: true` or delete it.
|
|
36
50
|
- **AI crawler policy:** set `seo.robots.aiTraining` / `aiSearch` only on the owner's instruction.
|
|
@@ -64,10 +78,11 @@ canonical: /other/ # canonical override (path or absolute URL); page then
|
|
|
64
78
|
image: /images/og.png # og:image (made absolute using `site`)
|
|
65
79
|
type: article # og:type + Article JSON-LD (default: website)
|
|
66
80
|
author: Jane Doe # Article author
|
|
67
|
-
datePublished: 2026-01-31
|
|
81
|
+
datePublished: 2026-01-31 # a future date schedules the page: it's skipped until a build on or after that day
|
|
68
82
|
dateModified: 2026-02-15 # also the sitemap <lastmod> (otherwise datePublished, or git with seo.lastmod: "git")
|
|
69
83
|
order: 2 # position in menus built with tree() or sort: "order" (lower first)
|
|
70
84
|
navTitle: Pricing # shorter label for menus and breadcrumbs (PageSummary.label)
|
|
85
|
+
tags: [SEO, AI] # each tag gets a page at /tags/<tag>/
|
|
71
86
|
---
|
|
72
87
|
```
|
|
73
88
|
|
|
@@ -88,7 +103,7 @@ Hacked sites are usually abused for hidden spam links and doorway pages. vagejs
|
|
|
88
103
|
|
|
89
104
|
HTML inside Markdown is sanitized: scripts, event handlers, `javascript:` links, iframes and forms are removed. Embeds (e.g. video) belong in a layout, which is trusted code. The owner can trust all authors with `markdown: { html: "allow" }`; don't set that yourself. Values inside `{ }` in frontmatter that contain a comma or colon must be quoted; `vage check` warns about empty values (`frontmatter-empty`). Never put secrets in `public/` (it is published, and the build refuses secret-looking files and symlinks there). `npm run dev -- --host` exposes the dev server to your network; it serves only pages and `public/`, but use it only on trusted networks.
|
|
90
105
|
|
|
91
|
-
With `deploy.target: "cloudflare-workers"
|
|
106
|
+
With `deploy.target: "cloudflare-workers"`, `"cloudflare-pages"` or `"netlify"`, the build writes `_headers` with security headers and a Content-Security-Policy computed from the pages (inline scripts are allowed by hash). Don't use inline event handlers (`onclick=`); add sources the site really needs to `security.cspSources`.
|
|
92
107
|
|
|
93
108
|
## Layouts
|
|
94
109
|
|
|
@@ -113,14 +128,14 @@ From the published pages and config (never from `public/`; a hand-written `publi
|
|
|
113
128
|
- `sitemap.xml`: indexable, self-canonical, non-draft pages. Needs `site`.
|
|
114
129
|
- `llms.txt` (+ `llms-full.txt` with `llmsTxt: { full: true }`): same pages. An emerging convention; not a guarantee that AI tools use it.
|
|
115
130
|
- Redirects: `redirects: { "/old/": "/new/" }` become noindex meta-refresh pages (dev server answers 301).
|
|
116
|
-
- With `seo.feeds`: `<section>/feed.xml` (RSS). With `seo.markdownCopies`: `/<path>.md` copies of listed pages. With `images.optimize`: resized images in `/_vage/img/`.
|
|
131
|
+
- Tag pages at `/tags/<tag>/` for every tag in frontmatter (`tags.pages`). With `seo.feeds`: `<section>/feed.xml` (RSS). With `seo.markdownCopies`: `/<path>.md` copies of listed pages. With `images.optimize`: resized images in `/_vage/img/`.
|
|
117
132
|
|
|
118
133
|
|
|
119
134
|
## Config
|
|
120
135
|
|
|
121
136
|
`vage.config.ts` keys: `site` (origin only, no path), `title`, `description`, `lang`,
|
|
122
|
-
`seo: { robots, sitemap, llmsTxt, structuredData, feeds, markdownCopies, lastmod }`, `redirects`, `markdown`,
|
|
123
|
-
`images: { optimize, widths, quality, sizes }`, `performance`, `deploy: { target, cloudflareWorker, cloudflareProject }`,
|
|
137
|
+
`seo: { robots, sitemap, llmsTxt, structuredData, feeds, markdownCopies, lastmod }`, `redirects`, `markdown: { html, inlineStyles, headingIds }`, `tags: { pages, layout }`,
|
|
138
|
+
`images: { optimize, widths, quality, sizes }`, `performance`, `deploy: { target, cloudflareWorker, cloudflareProject, netlifySite }`,
|
|
124
139
|
`security: { headers, csp, cspSources, outboundLinks, spamPatterns }`, `integrations`, `guidelines`.
|
|
125
140
|
Keys not in this list are an error; `npx vage info --json` has the full schema.
|
|
126
141
|
|
|
@@ -140,13 +155,14 @@ Always ask the site owner before publishing or deploying. Show them the plan fir
|
|
|
140
155
|
- `npx vage deploy` with `deploy: { target: "cloudflare-workers", cloudflareWorker: "<name>" }`: Cloudflare's current way to host static sites. Uploads a new version and switches all traffic to it, so custom domains stay up. For custom domains or routes, the owner adds a `wrangler.jsonc` (vagejs then uses it). Config redirects become real 301s (`_redirects`).
|
|
141
156
|
- `npx vage deploy --target cloudflare-pages --project <name>`: older Cloudflare Pages projects. Non-production branches get preview URLs. Cloudflare now turns new Pages projects into Workers; use `cloudflare-workers` for new sites.
|
|
142
157
|
- Both Cloudflare targets report success only when Wrangler succeeds and the site responds. They need `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` or `npx wrangler login`.
|
|
158
|
+
- `npx vage deploy` with `deploy: { target: "netlify", netlifySite: "<site name or ID>" }`: uploads `dist/` to the Netlify site's production deploy (create the site in Netlify first). The build writes Netlify's `_headers` (security headers) and `_redirects` (config redirects as forced 301s). It reports success only when netlify-cli succeeds and the site responds. Needs `NETLIFY_AUTH_TOKEN` or `npx netlify-cli login`.
|
|
143
159
|
- `npx vage deploy`: GitHub Pages (default target). Adds `.github/workflows/deploy.yml` (if missing), publishes, enables Pages via `gh` if needed, then waits for the Actions run. It says "deployed" only when GitHub reports success.
|
|
144
160
|
|
|
145
161
|
After deploying, `npx vage indexnow --changed-since HEAD~1` (needs `integrations.indexNow.key`) tells IndexNow search engines about changed pages. It's a hint, not a guarantee of indexing; ask the owner first.
|
|
146
162
|
|
|
147
163
|
GitHub Pages needs a custom domain or a `<user>.github.io` repository, because vagejs does not support base paths yet; `vage deploy` stops before pushing when the site would land in a subfolder.
|
|
148
164
|
|
|
149
|
-
Other static hosts (Vercel
|
|
165
|
+
Other static hosts (Vercel and the like) work without a vagejs deploy command: build command `npm run build`, output folder `dist`, no framework preset. Config redirects become small forwarding pages there, and the `_headers` security headers apply only on Cloudflare and Netlify.
|
|
150
166
|
`.github/workflows/ci.yml` runs check, build and audit on every push and PR, and uploads `dist/` as a downloadable preview artifact.
|
|
151
167
|
|
|
152
168
|
## Analytics and search reports
|
|
@@ -166,7 +182,7 @@ Off unless configured in `vage.config.ts` under `integrations`. Ask the site own
|
|
|
166
182
|
|
|
167
183
|
## Not implemented yet
|
|
168
184
|
|
|
169
|
-
Bing Webmaster Tools API,
|
|
185
|
+
Bing Webmaster Tools API, a Vercel adapter, CMS content sources, multi-language sites, base paths. Do not use or document them as available. A passing `check` does not guarantee indexing, rankings or AI citations.
|
|
170
186
|
|
|
171
187
|
## Favicon
|
|
172
188
|
|