vagejs 0.4.0 → 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 +16 -6
- package/dist/checks/check.js +63 -5
- package/dist/cli/index.js +48 -5
- package/dist/cli/info.d.ts +1 -0
- package/dist/cli/info.js +6 -3
- package/dist/content/markdown.d.ts +1 -0
- package/dist/content/markdown.js +4 -0
- package/dist/content/sanitize.d.ts +5 -2
- package/dist/content/sanitize.js +50 -5
- package/dist/core/build.d.ts +5 -0
- package/dist/core/build.js +1 -0
- package/dist/core/config.d.ts +7 -0
- package/dist/core/config.js +22 -3
- 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 +3 -3
- 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/server.js +13 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp/server.d.ts +5 -1
- package/dist/mcp/server.js +3 -2
- package/package.json +1 -1
- package/templates/starter/AGENTS.md +21 -7
- package/templates/starter/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,16 +15,16 @@
|
|
|
15
15
|
|
|
16
16
|
**A free static site generator built for AI coding assistants.** Your assistant (Claude Code, Cursor, Codex, Copilot) writes the pages; vagejs checks them before anything goes live. Write pages in Markdown and get a fast static site with SEO, `llms.txt`, accessibility and security handled for you. **Pre-release (0.x).**
|
|
17
17
|
|
|
18
|
-
[Website](https://vagejs.com) · [Docs](https://vagejs.com/docs/) · [Use with AI assistants](https://vagejs.com/agents/) · [Changelog](https://vagejs.com/changelog/) · [Discussions](https://github.com/agevenkat/vagejs/discussions)
|
|
18
|
+
[Website](https://vagejs.com) · [Docs](https://vagejs.com/docs/) · [Use with AI assistants](https://vagejs.com/agents/) · [Changelog](https://vagejs.com/changelog/) · [Discussions](https://github.com/agevenkat/vagejs/discussions) · [LinkedIn](https://www.linkedin.com/company/vagejs/)
|
|
19
19
|
|
|
20
20
|
<p align="center"><img src="https://raw.githubusercontent.com/agevenkat/vagejs/main/assets/demo.svg" alt="Terminal demo: create a vagejs site, install it, npm run check catches a broken link and names the file, the link is fixed, the check passes, and npm start runs the site." width="760"></p>
|
|
21
21
|
|
|
22
22
|
- **Static by default**: plain HTML, no client JavaScript unless you add analytics.
|
|
23
23
|
- **SEO built in**: `robots.txt`, `sitemap.xml`, `llms.txt`, canonical URLs, Open Graph and JSON-LD, generated from your pages.
|
|
24
24
|
- **Checks before you ship**: `vage check` (links, SEO, structured data, accessibility) and `vage audit` (page-weight budgets, axe, optional Lighthouse and PageSpeed).
|
|
25
|
-
- **Safe by default**: page content is sanitized; secrets, build output and force-pushes are refused; Cloudflare deploys get a computed Content-Security-Policy.
|
|
25
|
+
- **Safe by default**: page content is sanitized; secrets, build output and force-pushes are refused; Cloudflare and Netlify deploys get a computed Content-Security-Policy.
|
|
26
26
|
- **Agent-friendly**: every site ships with `AGENTS.md`, and `vage info --json` describes every command and config option.
|
|
27
|
-
- **Runs everywhere**: macOS, Windows and Linux, tested on every change. Deploys to GitHub Pages
|
|
27
|
+
- **Runs everywhere**: macOS, Windows and Linux, tested on every change. Deploys to GitHub Pages, Cloudflare Workers and Netlify built in; any other static host (Vercel) works with `npm run build` and the `dist` folder.
|
|
28
28
|
|
|
29
29
|
## Quick start
|
|
30
30
|
|
|
@@ -36,9 +36,17 @@ cd my-site
|
|
|
36
36
|
npm install
|
|
37
37
|
npm start
|
|
38
38
|
```
|
|
39
|
-
|
|
40
39
|
`npm start` opens the site in your browser. Edit `src/pages/index.md` and the page reloads. (`npm run dev` does the same without opening a browser, and prints the address.)
|
|
41
40
|
|
|
41
|
+
### Windows
|
|
42
|
+
|
|
43
|
+
vagejs works on Windows and is tested on Windows on every change.
|
|
44
|
+
|
|
45
|
+
- **Node.js:** install Node.js **22.12 or newer** before creating a project. Then open a new PowerShell or Command Prompt window and check it with `node --version` and `npm --version`.
|
|
46
|
+
- **PowerShell or Command Prompt:** the `npm` and `npx` commands in this README work in both. Command Prompt doesn't treat `#` as a comment, so leave out the `# …` notes shown after some commands.
|
|
47
|
+
- **Line endings:** Windows usually saves files with CRLF line endings. vagejs handles them, so there's no need to convert files to LF.
|
|
48
|
+
- **HTTP 431 in the browser:** open the direct address (`[::1]` or `127.0.0.1`) that `npm start` and `npm run dev` print. It's the same server, without the cookies other local projects left on `localhost`.
|
|
49
|
+
|
|
42
50
|
## Using vagejs
|
|
43
51
|
|
|
44
52
|
### Project layout
|
|
@@ -71,7 +79,7 @@ description: Plans and prices.
|
|
|
71
79
|
Our plans start at …
|
|
72
80
|
```
|
|
73
81
|
|
|
74
|
-
Save it as `src/pages/pricing.md` and it's served at `/pricing/`. `title` is required. `description`, `draft`, `noindex`, `canonical`, `image`, `type: article`, `author`, `datePublished` and `
|
|
82
|
+
Save it as `src/pages/pricing.md` and it's served at `/pricing/`. `title` is required. `description`, `draft`, `noindex`, `canonical`, `image`, `type: article`, `author`, `datePublished`, `dateModified` and `tags` are optional (a future `datePublished` schedules the page; each tag gets a page at `/tags/<tag>/`); see [`templates/starter/AGENTS.md`](templates/starter/AGENTS.md).
|
|
75
83
|
|
|
76
84
|
Images in `public/` used as `` get their `width` and `height` from the file, so the page doesn't jump while loading. With `images: { optimize: true }` (and `npm install --save-dev sharp`), vagejs also makes resized WebP copies of images in page content and offers them with `srcset`. Add `avif: true` for AVIF copies too, offered first through `<picture>` (usually smaller; slower to make). `vage audit` counts the copy browsers download, not the original.
|
|
77
85
|
|
|
@@ -145,6 +153,7 @@ npx vage deploy:github --remote https://github.com/you/site.git # check, build
|
|
|
145
153
|
npx vage deploy # GitHub Pages via Actions
|
|
146
154
|
npx vage deploy --target cloudflare-workers --project my-site # Cloudflare Workers: new version, no downtime
|
|
147
155
|
npx vage deploy --target cloudflare-pages --project my-site # older Cloudflare Pages projects
|
|
156
|
+
npx vage deploy --target netlify --project my-site # Netlify production deploy (deploy.target: "netlify")
|
|
148
157
|
```
|
|
149
158
|
|
|
150
159
|
Every deploy shows its plan and asks for confirmation (`--dry-run` to preview, `--yes` to skip). It reports success only after the host confirms.
|
|
@@ -201,6 +210,7 @@ vage deploy:github [--remote URL] [--branch B] [-m MSG] [--dry-run] [--yes] [--j
|
|
|
201
210
|
vage deploy [--remote URL] [--no-wait] [--dry-run] [--yes] # GitHub Pages via Actions; confirms with GitHub
|
|
202
211
|
vage deploy --target cloudflare-workers [--project NAME] # new Worker version at 100%; custom domains stay up
|
|
203
212
|
vage deploy --target cloudflare-pages --project NAME [--branch B] # Wrangler upload; preview URL per branch
|
|
213
|
+
vage deploy --target netlify [--project SITE] # netlify-cli production deploy, then a live check
|
|
204
214
|
vage indexnow --changed-since REF | --urls … | --all # IndexNow URL notification (asks first)
|
|
205
215
|
vage connect gsc|ga4 / vage disconnect google # read-only Google OAuth (PKCE, loopback); token outside the repo
|
|
206
216
|
vage connect pagespeed|crux / vage disconnect … # save your own Google API key for audit --url (outside the repo)
|
|
@@ -211,7 +221,7 @@ vage info [--json] # commands + config/frontmatte
|
|
|
211
221
|
|
|
212
222
|
## Planned, not implemented
|
|
213
223
|
|
|
214
|
-
Bing Webmaster Tools API,
|
|
224
|
+
Bing Webmaster Tools API, a Vercel adapter, CMS content sources, multi-language sites, base paths, sitemap indexes.
|
|
215
225
|
|
|
216
226
|
## Security
|
|
217
227
|
|
package/dist/checks/check.js
CHANGED
|
@@ -62,10 +62,17 @@ export function checkSite(root, model) {
|
|
|
62
62
|
if (e.route)
|
|
63
63
|
outputs.add(`${e.route}index.html`);
|
|
64
64
|
const drafts = new Set(model.draftRoutes);
|
|
65
|
+
const scheduled = new Map(model.skippedScheduled.map((s) => [s.route, s.date]));
|
|
65
66
|
const pages = model.notFound ? [...model.pages, model.notFound] : model.pages;
|
|
66
|
-
|
|
67
|
+
const docs = pages.map((page) => ({ page, doc: parse(page.html) }));
|
|
68
|
+
// Link targets on each output file: element ids and <a name>, for #fragment links.
|
|
69
|
+
const anchors = new Map(docs.map(({ page, doc }) => [
|
|
70
|
+
`/${page.outFile}`,
|
|
71
|
+
new Set([...doc.querySelectorAll("[id]").map((el) => el.getAttribute("id")), ...doc.querySelectorAll("a[name]").map((el) => el.getAttribute("name"))]),
|
|
72
|
+
]));
|
|
73
|
+
for (const { page, doc } of docs) {
|
|
67
74
|
const add = (f) => findings.push({ ...f, page: page.route, source: page.source });
|
|
68
|
-
checkPage(page,
|
|
75
|
+
checkPage(page, doc, model, outputs, drafts, scheduled, anchors, add);
|
|
69
76
|
}
|
|
70
77
|
findings.push(...checkDuplicates(listedPages(model)));
|
|
71
78
|
findings.push(...checkRobots(model));
|
|
@@ -73,7 +80,7 @@ export function checkSite(root, model) {
|
|
|
73
80
|
findings.push(...checkOutboundLinks(root, model));
|
|
74
81
|
return findings;
|
|
75
82
|
}
|
|
76
|
-
function checkPage(page, doc, model, outputs, drafts, add) {
|
|
83
|
+
function checkPage(page, doc, model, outputs, drafts, scheduled, anchors, add) {
|
|
77
84
|
const indexable = !page.meta.noindex;
|
|
78
85
|
if (model.site.security.spamPatterns) {
|
|
79
86
|
const text = `${doc.querySelector("body")?.text ?? ""} ${doc.querySelectorAll("a[href]").map((a) => a.getAttribute("href")).join(" ")}`;
|
|
@@ -101,6 +108,25 @@ function checkPage(page, doc, model, outputs, drafts, add) {
|
|
|
101
108
|
if (titles.length !== 1 || !titles[0].text.trim()) {
|
|
102
109
|
add({ rule: "title", severity: "error", message: `Expected exactly one non-empty <title>, found ${titles.length}.`, docs: DOCS.title });
|
|
103
110
|
}
|
|
111
|
+
// Only pages shown in search results can be shortened there.
|
|
112
|
+
const renderedTitle = titles[0]?.text.trim() ?? "";
|
|
113
|
+
if (indexable && renderedTitle.length > 60) {
|
|
114
|
+
add({
|
|
115
|
+
rule: "title-length",
|
|
116
|
+
severity: "warning",
|
|
117
|
+
message: `Rendered <title> in "${page.source}" is ${renderedTitle.length} characters; it may be shortened in search results.`,
|
|
118
|
+
docs: DOCS.title,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const description = doc.querySelector('meta[name="description"]')?.getAttribute("content")?.trim() ?? "";
|
|
122
|
+
if (indexable && description.length > 160) {
|
|
123
|
+
add({
|
|
124
|
+
rule: "meta-description-length",
|
|
125
|
+
severity: "warning",
|
|
126
|
+
message: `Meta description in "${page.source}" is ${description.length} characters; it may be shortened in search results.`,
|
|
127
|
+
docs: DOCS.description,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
104
130
|
if (indexable && !doc.querySelector('meta[name="description"]')?.getAttribute("content")) {
|
|
105
131
|
add({
|
|
106
132
|
rule: "meta-description",
|
|
@@ -148,6 +174,10 @@ function checkPage(page, doc, model, outputs, drafts, add) {
|
|
|
148
174
|
if (!a.text.trim() && !a.getAttribute("aria-label") && !a.querySelector("img[alt]:not([alt=''])")) {
|
|
149
175
|
add({ rule: "link-text", severity: "warning", message: `Link to "${href}" has no accessible text.`, docs: DOCS.linkPurpose });
|
|
150
176
|
}
|
|
177
|
+
if (/^#./.test(href.trim())) {
|
|
178
|
+
checkFragment(href, `/${page.outFile}`, anchors, add);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
151
181
|
const target = internalPath(href, page.route, model.site.site);
|
|
152
182
|
if (target === undefined)
|
|
153
183
|
continue;
|
|
@@ -156,12 +186,19 @@ function checkPage(page, doc, model, outputs, drafts, add) {
|
|
|
156
186
|
continue;
|
|
157
187
|
}
|
|
158
188
|
const resolved = resolveOutput(target, outputs);
|
|
189
|
+
if (resolved === "ok")
|
|
190
|
+
checkFragment(href, target.endsWith("/") ? `${target}index.html` : target, anchors, add);
|
|
159
191
|
if (resolved === "missing") {
|
|
160
|
-
const
|
|
192
|
+
const route = target.endsWith("/") ? target : `${target}/`;
|
|
193
|
+
const date = scheduled.get(route);
|
|
161
194
|
add({
|
|
162
195
|
rule: "link-broken",
|
|
163
196
|
severity: "error",
|
|
164
|
-
message:
|
|
197
|
+
message: drafts.has(route)
|
|
198
|
+
? `Link to "${href}" points to a draft page, which is not published.`
|
|
199
|
+
: date
|
|
200
|
+
? `Link to "${href}" points to a page scheduled for ${date}, which is not published yet.`
|
|
201
|
+
: `Link to "${href}" does not match any page or file.`,
|
|
165
202
|
});
|
|
166
203
|
}
|
|
167
204
|
else if (resolved === "needs-slash") {
|
|
@@ -423,6 +460,27 @@ function internalPath(href, base, site) {
|
|
|
423
460
|
return MALFORMED;
|
|
424
461
|
}
|
|
425
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* A #fragment must match an id (or <a name>) on the page it points to; otherwise the
|
|
465
|
+
* page opens at the top instead of the section. "#top" and text fragments (#:~:text=)
|
|
466
|
+
* work without one. Files that aren't rendered pages (PDFs, redirects) aren't checked.
|
|
467
|
+
*/
|
|
468
|
+
function checkFragment(href, outFile, anchors, add) {
|
|
469
|
+
const ids = anchors.get(outFile);
|
|
470
|
+
const hash = href.indexOf("#");
|
|
471
|
+
if (!ids || hash === -1)
|
|
472
|
+
return;
|
|
473
|
+
let fragment;
|
|
474
|
+
try {
|
|
475
|
+
fragment = decodeURIComponent(href.slice(hash + 1));
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
fragment = href.slice(hash + 1);
|
|
479
|
+
}
|
|
480
|
+
if (!fragment || fragment.toLowerCase() === "top" || fragment.startsWith(":~:") || ids.has(fragment))
|
|
481
|
+
return;
|
|
482
|
+
add({ rule: "link-fragment", severity: "warning", message: `Link to "${href}": no element with id "${fragment}" on that page, so it opens at the top.` });
|
|
483
|
+
}
|
|
426
484
|
function resolveOutput(target, outputs) {
|
|
427
485
|
if (target.endsWith("/"))
|
|
428
486
|
return outputs.has(`${target}index.html`) ? "ok" : "missing";
|
package/dist/cli/index.js
CHANGED
|
@@ -56,6 +56,8 @@ cli
|
|
|
56
56
|
console.log(` ${"".padEnd(30)} -> ${file}`);
|
|
57
57
|
for (const draft of result.skippedDrafts)
|
|
58
58
|
console.log(` (draft, skipped) ${draft}`);
|
|
59
|
+
for (const s of result.skippedScheduled)
|
|
60
|
+
console.log(` (scheduled for ${s.date}, skipped) ${s.source}`);
|
|
59
61
|
for (const warning of result.warnings)
|
|
60
62
|
console.log(`\nWarning: ${warning}`);
|
|
61
63
|
console.log(`\nBuilt ${result.pages.length} page(s) into ${result.outDir} in ${result.durationMs}ms`);
|
|
@@ -203,10 +205,10 @@ cli
|
|
|
203
205
|
reportPublish(result);
|
|
204
206
|
}));
|
|
205
207
|
cli
|
|
206
|
-
.command("deploy", "Deploy: GitHub Pages (workflow + push, waits for GitHub), Cloudflare Workers (new version, no downtime)
|
|
208
|
+
.command("deploy", "Deploy: GitHub Pages (workflow + push, waits for GitHub), Cloudflare Workers (new version, no downtime), Cloudflare Pages or Netlify")
|
|
207
209
|
.option("--root <dir>", "Project root", { default: "." })
|
|
208
|
-
.option("--target <host>", "github-pages, cloudflare-workers
|
|
209
|
-
.option("--project <name>", "Cloudflare Pages project or
|
|
210
|
+
.option("--target <host>", "github-pages, cloudflare-workers, cloudflare-pages or netlify (default: deploy.target in config)")
|
|
211
|
+
.option("--project <name>", "Cloudflare Pages project, Worker or Netlify site name (default: deploy.cloudflareProject / cloudflareWorker / netlifySite)")
|
|
210
212
|
.option("--remote <url>", "Git URL to add as origin if there is none")
|
|
211
213
|
.option("--branch <name>", "Branch for a new repository (default: main)")
|
|
212
214
|
.option("-m, --message <msg>", "Commit message")
|
|
@@ -222,8 +224,13 @@ cli
|
|
|
222
224
|
return deployToCloudflare(opts, config.deploy.cloudflareProject);
|
|
223
225
|
if (target === "cloudflare-workers")
|
|
224
226
|
return deployToWorkers(opts, config.deploy.cloudflareWorker ?? config.deploy.cloudflareProject, config.site);
|
|
227
|
+
if (target === "netlify") {
|
|
228
|
+
if (config.deploy.target !== "netlify")
|
|
229
|
+
throw new VageError('Set deploy.target to "netlify" in vage.config.ts, so the build writes Netlify\'s _redirects and _headers.');
|
|
230
|
+
return deployToNetlify(opts, config.deploy.netlifySite, config.site);
|
|
231
|
+
}
|
|
225
232
|
if (target !== "github-pages")
|
|
226
|
-
throw new VageError(`Unknown --target "${target}". Use github-pages, cloudflare-workers
|
|
233
|
+
throw new VageError(`Unknown --target "${target}". Use github-pages, cloudflare-workers, cloudflare-pages or netlify.`);
|
|
227
234
|
const { publish } = await import("../deploy/publish.js");
|
|
228
235
|
const pages = await import("../deploy/github-pages.js");
|
|
229
236
|
// Stop before pushing a site GitHub Pages would serve from a subfolder (its links would break).
|
|
@@ -489,7 +496,9 @@ cli
|
|
|
489
496
|
urls = opts.urls.split(",").map((u) => abs(u.trim()));
|
|
490
497
|
else if (opts.changedSince) {
|
|
491
498
|
const { changedPageRoutes } = await import("../deploy/git.js");
|
|
492
|
-
|
|
499
|
+
// Deleted pages are submitted too (so engines drop them); drafts and scheduled pages aren't live yet.
|
|
500
|
+
const unpublished = new Set([...model.draftRoutes, ...model.skippedScheduled.map((s) => s.route)]);
|
|
501
|
+
urls = (await changedPageRoutes(root, opts.changedSince)).filter((r) => !unpublished.has(r)).map(abs);
|
|
493
502
|
if (!urls.length)
|
|
494
503
|
return opts.json ? print({ ok: true, submitted: 0 }) : console.log(`No page changes since ${opts.changedSince}.`);
|
|
495
504
|
}
|
|
@@ -647,6 +656,40 @@ async function deployToWorkers(opts, configuredWorker, siteUrl) {
|
|
|
647
656
|
if (deployment.status === "failure")
|
|
648
657
|
process.exit(1);
|
|
649
658
|
}
|
|
659
|
+
async function deployToNetlify(opts, configuredSite, siteUrl) {
|
|
660
|
+
const site = opts.project ?? configuredSite;
|
|
661
|
+
if (!site)
|
|
662
|
+
throw new VageError("No Netlify site. Pass --project <site name or ID> or set deploy.netlifySite in vage.config.ts (create the site in Netlify first).");
|
|
663
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(site))
|
|
664
|
+
throw new VageError(`"${site}" isn't a Netlify site name or ID (lowercase letters, digits and hyphens).`);
|
|
665
|
+
const root = path.resolve(opts.root);
|
|
666
|
+
const { check } = await import("../checks/check.js");
|
|
667
|
+
const checked = await check(root);
|
|
668
|
+
if (checked.errors)
|
|
669
|
+
throw new VageError(`vage check found ${checked.errors} error(s). Fix them before deploying.`);
|
|
670
|
+
const result = await build(root);
|
|
671
|
+
const { deployNetlify, NETLIFY_CLI } = await import("../deploy/netlify.js");
|
|
672
|
+
const steps = [
|
|
673
|
+
`upload ${result.outDir} (${result.pages.length} page(s), ${result.files.length} generated file(s)) to the Netlify site "${site}" (production)`,
|
|
674
|
+
`runs: npx ${NETLIFY_CLI} deploy --dir dist --prod --no-build --site ${site}`,
|
|
675
|
+
`verifies: ${siteUrl ?? "the site's Netlify URL"} answers 200`,
|
|
676
|
+
];
|
|
677
|
+
const lines = ["Deploy plan (Netlify)", ...steps.map((s) => ` • ${s}`)];
|
|
678
|
+
if (!(await askConfirm(lines, opts))) {
|
|
679
|
+
if (opts.dryRun)
|
|
680
|
+
return opts.json ? print({ ok: true, dryRun: true, target: "netlify", site, steps }) : console.log("Dry run: nothing deployed.");
|
|
681
|
+
return opts.json ? print({ ok: false, status: "aborted" }) : console.log("Nothing deployed.");
|
|
682
|
+
}
|
|
683
|
+
const deployment = await deployNetlify(root, { site, siteUrl });
|
|
684
|
+
if (opts.json)
|
|
685
|
+
print({ ok: deployment.status !== "failure", target: "netlify", deployment });
|
|
686
|
+
else if (deployment.status === "success")
|
|
687
|
+
console.log(`Deployed and verified: ${deployment.pageUrl}${deployment.deployId ? ` (deploy ${deployment.deployId})` : ""}`);
|
|
688
|
+
else
|
|
689
|
+
console.log(`Deployment ${deployment.status === "failure" ? "FAILED" : "not confirmed"}: ${deployment.reason}`);
|
|
690
|
+
if (deployment.status === "failure")
|
|
691
|
+
process.exit(1);
|
|
692
|
+
}
|
|
650
693
|
async function confirmPlan(plan, opts, extra) {
|
|
651
694
|
const lines = [`Publish plan for ${plan.root}`];
|
|
652
695
|
if (plan.initRepo)
|
package/dist/cli/info.d.ts
CHANGED
package/dist/cli/info.js
CHANGED
|
@@ -34,7 +34,7 @@ export function frameworkInfo(cli, version) {
|
|
|
34
34
|
"two files mapping to one route is an error",
|
|
35
35
|
],
|
|
36
36
|
layouts: "see `layouts`",
|
|
37
|
-
drafts: "draft: true pages render in `vage dev` for preview, are skipped by build, and never appear in `pages
|
|
37
|
+
drafts: "draft: true pages render in `vage dev` for preview, are skipped by build, and never appear in `pages`. A future datePublished does the same until a build on or after that date (scheduled pages)",
|
|
38
38
|
},
|
|
39
39
|
layouts: {
|
|
40
40
|
file: "src/layouts/<name>.ts, chosen with `layout: <name>` in frontmatter (default: default)",
|
|
@@ -88,8 +88,9 @@ export function frameworkInfo(cli, version) {
|
|
|
88
88
|
"llms-full.txt (opt-in)",
|
|
89
89
|
"404.html",
|
|
90
90
|
"redirect pages",
|
|
91
|
+
"/tags/<tag>/ pages for tags used in frontmatter (tags.pages)",
|
|
91
92
|
"<indexnow-key>.txt (opt-in)",
|
|
92
|
-
"_redirects and _headers (cloudflare-workers
|
|
93
|
+
"_redirects and _headers (cloudflare-workers, cloudflare-pages and netlify targets)",
|
|
93
94
|
"<section>/feed.xml (seo.feeds)",
|
|
94
95
|
"/<path>.md Markdown copies of listed pages (seo.markdownCopies)",
|
|
95
96
|
"/_vage/img/*.webp resized images (images.optimize)",
|
|
@@ -102,10 +103,12 @@ export function frameworkInfo(cli, version) {
|
|
|
102
103
|
VAGE_CONFIG_DIR: "optional; where the Google token is stored (default: OS user config dir)",
|
|
103
104
|
CLOUDFLARE_API_TOKEN: "for Cloudflare deploys (cloudflare-workers or cloudflare-pages targets), or run `npx wrangler login`",
|
|
104
105
|
CLOUDFLARE_ACCOUNT_ID: "for Cloudflare deploys (cloudflare-workers or cloudflare-pages targets)",
|
|
106
|
+
NETLIFY_AUTH_TOKEN: "for Netlify deploys (netlify target), or run `npx netlify-cli login`",
|
|
105
107
|
},
|
|
106
108
|
notImplemented: [
|
|
107
109
|
"Bing Webmaster Tools API (use IndexNow for URL submission)",
|
|
108
|
-
"
|
|
110
|
+
"A Vercel deploy adapter (Vercel serves dist/ as plain files)",
|
|
111
|
+
"RSS feeds per tag",
|
|
109
112
|
"CMS / external content-source adapters",
|
|
110
113
|
"hreflang / multi-language sites, base paths, sitemap indexes",
|
|
111
114
|
],
|
|
@@ -16,6 +16,7 @@ export declare const frontmatterSchema: z.ZodObject<{
|
|
|
16
16
|
dateModified: z.ZodOptional<z.ZodPreprocess<z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>, unknown>>;
|
|
17
17
|
order: z.ZodOptional<z.ZodNumber>;
|
|
18
18
|
navTitle: z.ZodOptional<z.ZodString>;
|
|
19
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
19
20
|
}, z.core.$loose>;
|
|
20
21
|
export type Frontmatter = z.output<typeof frontmatterSchema>;
|
|
21
22
|
export interface MarkdownPage {
|
package/dist/content/markdown.js
CHANGED
|
@@ -30,6 +30,10 @@ export const frontmatterSchema = z.looseObject({
|
|
|
30
30
|
dateModified: isoDate.optional().describe("ISO date or datetime; also the sitemap <lastmod>."),
|
|
31
31
|
order: z.number().finite().optional().describe('Position in navigation lists (lower first). Used by collection(pages, { sort: "order" }) and tree().'),
|
|
32
32
|
navTitle: singleLine.min(1).optional().describe("Shorter title for menus and breadcrumbs. Defaults to the title."),
|
|
33
|
+
tags: z
|
|
34
|
+
.array(singleLine.regex(/[\p{L}\p{N}]/u, "each tag needs a letter or digit (it becomes the address /tags/<tag>/)"))
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('Topics, e.g. [SEO, AI]. Each tag gets a page at /tags/<tag>/ listing its pages (tags.pages); collection(pages, { tag: "SEO" }) picks them.'),
|
|
33
37
|
});
|
|
34
38
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
35
39
|
const marked = new Marked({ gfm: true });
|
|
@@ -18,6 +18,9 @@ export declare function sanitizeHtml(html: string, options?: {
|
|
|
18
18
|
publicDir?: string;
|
|
19
19
|
inlineStyles?: boolean;
|
|
20
20
|
variants?: Map<string, ImageVariant>;
|
|
21
|
+
headingIds?: boolean;
|
|
21
22
|
}): Promise<string>;
|
|
22
|
-
/** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
|
|
23
|
-
export declare function enhanceTrustedHtml(html: string, publicDir: string, variants?: Map<string, ImageVariant
|
|
23
|
+
/** For trusted content (`markdown.html: "allow"`): the same image hints and heading ids, applied through the DOM. */
|
|
24
|
+
export declare function enhanceTrustedHtml(html: string, publicDir: string, variants?: Map<string, ImageVariant>, headingIds?: boolean): Promise<string>;
|
|
25
|
+
/** "Quick start: 2 steps!" -> "quick-start-2-steps". Letters in any script are kept; accents are dropped. */
|
|
26
|
+
export declare function headingSlug(text: string): string;
|
package/dist/content/sanitize.js
CHANGED
|
@@ -29,9 +29,17 @@ export async function sanitizeHtml(html, options = {}) {
|
|
|
29
29
|
first = false;
|
|
30
30
|
}
|
|
31
31
|
};
|
|
32
|
+
// A heading's own id is kept even when DOMPurify's clobbering filter would drop it ("title",
|
|
33
|
+
// "images"), so it behaves like the ids vagejs adds and links to it keep working.
|
|
34
|
+
const keepHeadingId = (node, data) => {
|
|
35
|
+
if (data.attrName === "id" && /^H[23]$/.test(node.nodeName))
|
|
36
|
+
data.forceKeepAttr = true;
|
|
37
|
+
};
|
|
32
38
|
if (publicDir) {
|
|
33
39
|
purifier.addHook("afterSanitizeAttributes", hook);
|
|
34
40
|
}
|
|
41
|
+
if (options.headingIds)
|
|
42
|
+
purifier.addHook("uponSanitizeAttribute", keepHeadingId);
|
|
35
43
|
const config = {
|
|
36
44
|
// font/marquee/center: legacy ways to hide or disguise text (white-on-white, 1px scrollers).
|
|
37
45
|
FORBID_TAGS: ["style", "form", "input", "button", "textarea", "select", "option", "base", "link", "meta", "object", "embed", "font", "marquee", "center"],
|
|
@@ -41,12 +49,16 @@ export async function sanitizeHtml(html, options = {}) {
|
|
|
41
49
|
FORBID_ATTR: options.inlineStyles ? ["xlink:href"] : ["style", "xlink:href"],
|
|
42
50
|
};
|
|
43
51
|
try {
|
|
44
|
-
|
|
52
|
+
const avif = [...(options.variants?.values() ?? [])].some((v) => v.avifSrcset);
|
|
53
|
+
if (!avif && !options.headingIds)
|
|
45
54
|
return purifier.sanitize(html, config);
|
|
46
|
-
//
|
|
55
|
+
// Heading ids and AVIF <picture> wrappers are added on DOMPurify's own output tree, then serialized once.
|
|
47
56
|
// (Editing sanitized HTML as a string and parsing it again could change its meaning.)
|
|
48
57
|
const fragment = purifier.sanitize(html, { ...config, RETURN_DOM_FRAGMENT: true });
|
|
49
|
-
|
|
58
|
+
if (options.headingIds)
|
|
59
|
+
addHeadingIds(fragment);
|
|
60
|
+
if (avif)
|
|
61
|
+
addAvifSources(fragment, options.variants);
|
|
50
62
|
const container = fragment.ownerDocument.createElement("div");
|
|
51
63
|
container.appendChild(fragment);
|
|
52
64
|
return container.innerHTML;
|
|
@@ -54,18 +66,51 @@ export async function sanitizeHtml(html, options = {}) {
|
|
|
54
66
|
finally {
|
|
55
67
|
if (publicDir)
|
|
56
68
|
purifier.removeHook("afterSanitizeAttributes", hook);
|
|
69
|
+
if (options.headingIds)
|
|
70
|
+
purifier.removeHook("uponSanitizeAttribute", keepHeadingId);
|
|
57
71
|
}
|
|
58
72
|
}
|
|
59
|
-
/** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
|
|
60
|
-
export async function enhanceTrustedHtml(html, publicDir, variants) {
|
|
73
|
+
/** For trusted content (`markdown.html: "allow"`): the same image hints and heading ids, applied through the DOM. */
|
|
74
|
+
export async function enhanceTrustedHtml(html, publicDir, variants, headingIds = false) {
|
|
61
75
|
const { jsdom } = await load();
|
|
62
76
|
const template = new jsdom.JSDOM("").window.document.createElement("template");
|
|
63
77
|
template.innerHTML = html;
|
|
64
78
|
template.content.querySelectorAll("img").forEach((img, i) => enhanceImage(img, publicDir, i === 0, variants?.get(img.getAttribute("src") ?? "")));
|
|
79
|
+
if (headingIds)
|
|
80
|
+
addHeadingIds(template.content);
|
|
65
81
|
if (variants)
|
|
66
82
|
addAvifSources(template.content, variants);
|
|
67
83
|
return template.innerHTML;
|
|
68
84
|
}
|
|
85
|
+
/** "Quick start: 2 steps!" -> "quick-start-2-steps". Letters in any script are kept; accents are dropped. */
|
|
86
|
+
export function headingSlug(text) {
|
|
87
|
+
return text
|
|
88
|
+
.normalize("NFKD")
|
|
89
|
+
// Only Latin accents are dropped; marks are part of other scripts (Hindi vowel signs, kana voicing).
|
|
90
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
91
|
+
.normalize("NFC")
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^\p{L}\p{M}\p{N}]+/gu, "-")
|
|
94
|
+
.replace(/^-+|-+$/g, "");
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Gives each h2 and h3 without an id one made from its text, so sections can be linked
|
|
98
|
+
* (/docs/setup/#quick-start). Ids already on the page are kept and never reused; repeats
|
|
99
|
+
* get -2, -3.
|
|
100
|
+
*/
|
|
101
|
+
function addHeadingIds(root) {
|
|
102
|
+
const used = new Set([...root.querySelectorAll("[id]")].map((el) => el.id));
|
|
103
|
+
for (const heading of root.querySelectorAll("h2, h3")) {
|
|
104
|
+
if (heading.id)
|
|
105
|
+
continue;
|
|
106
|
+
const base = headingSlug(heading.textContent ?? "") || "section";
|
|
107
|
+
let id = base;
|
|
108
|
+
for (let n = 2; used.has(id); n++)
|
|
109
|
+
id = `${base}-${n}`;
|
|
110
|
+
used.add(id);
|
|
111
|
+
heading.id = id;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
69
114
|
/**
|
|
70
115
|
* Offers AVIF first: <picture><source type="image/avif" srcset sizes><img …></picture>.
|
|
71
116
|
* Browsers without AVIF use the <img> and its WebP srcset. Only images vagejs gave a
|
package/dist/core/build.d.ts
CHANGED
|
@@ -14,6 +14,11 @@ export interface BuildResult {
|
|
|
14
14
|
}[];
|
|
15
15
|
files: string[];
|
|
16
16
|
skippedDrafts: string[];
|
|
17
|
+
/** Pages with a later datePublished, and the date they go live (on a build from that day). */
|
|
18
|
+
skippedScheduled: {
|
|
19
|
+
source: string;
|
|
20
|
+
date: string;
|
|
21
|
+
}[];
|
|
17
22
|
warnings: string[];
|
|
18
23
|
durationMs: number;
|
|
19
24
|
}
|
package/dist/core/build.js
CHANGED
|
@@ -54,6 +54,7 @@ export async function buildSite(root) {
|
|
|
54
54
|
redirects: model.redirects.map((r) => ({ from: r.from, to: r.to, output: r.outFile })),
|
|
55
55
|
files: [...model.files.keys()],
|
|
56
56
|
skippedDrafts: model.skippedDrafts,
|
|
57
|
+
skippedScheduled: model.skippedScheduled.map(({ source, date }) => ({ source, date })),
|
|
57
58
|
warnings: model.warnings,
|
|
58
59
|
durationMs: Math.round(performance.now() - start),
|
|
59
60
|
};
|
package/dist/core/config.d.ts
CHANGED
|
@@ -12,12 +12,17 @@ export declare const configSchema: z.ZodObject<{
|
|
|
12
12
|
avif: z.ZodDefault<z.ZodBoolean>;
|
|
13
13
|
sizes: z.ZodDefault<z.ZodString>;
|
|
14
14
|
}, z.core.$strict>>;
|
|
15
|
+
tags: z.ZodDefault<z.ZodObject<{
|
|
16
|
+
pages: z.ZodDefault<z.ZodBoolean>;
|
|
17
|
+
layout: z.ZodOptional<z.ZodString>;
|
|
18
|
+
}, z.core.$strict>>;
|
|
15
19
|
markdown: z.ZodDefault<z.ZodObject<{
|
|
16
20
|
html: z.ZodDefault<z.ZodEnum<{
|
|
17
21
|
allow: "allow";
|
|
18
22
|
sanitize: "sanitize";
|
|
19
23
|
}>>;
|
|
20
24
|
inlineStyles: z.ZodDefault<z.ZodBoolean>;
|
|
25
|
+
headingIds: z.ZodDefault<z.ZodBoolean>;
|
|
21
26
|
}, z.core.$strict>>;
|
|
22
27
|
seo: z.ZodDefault<z.ZodObject<{
|
|
23
28
|
robots: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
@@ -116,9 +121,11 @@ export declare const configSchema: z.ZodObject<{
|
|
|
116
121
|
"cloudflare-pages": "cloudflare-pages";
|
|
117
122
|
"cloudflare-workers": "cloudflare-workers";
|
|
118
123
|
"github-pages": "github-pages";
|
|
124
|
+
netlify: "netlify";
|
|
119
125
|
}>>;
|
|
120
126
|
cloudflareProject: z.ZodOptional<z.ZodString>;
|
|
121
127
|
cloudflareWorker: z.ZodOptional<z.ZodString>;
|
|
128
|
+
netlifySite: z.ZodOptional<z.ZodString>;
|
|
122
129
|
}, z.core.$strict>>;
|
|
123
130
|
security: z.ZodDefault<z.ZodObject<{
|
|
124
131
|
headers: z.ZodDefault<z.ZodBoolean>;
|
package/dist/core/config.js
CHANGED
|
@@ -194,6 +194,16 @@ export const configSchema = z.strictObject({
|
|
|
194
194
|
sizes: z.string().regex(/^[^<>"]+$/).default("(max-width: 48rem) 100vw, 48rem").describe('The sizes attribute: how wide images display, so browsers pick the right file.'),
|
|
195
195
|
})
|
|
196
196
|
.default({ optimize: false, widths: [480, 960, 1600], quality: 75, avif: false, sizes: "(max-width: 48rem) 100vw, 48rem" }),
|
|
197
|
+
tags: z
|
|
198
|
+
.strictObject({
|
|
199
|
+
pages: z.boolean().default(true).describe("Generate a page at /tags/<tag>/ for every tag used in page frontmatter, listing its pages, newest first."),
|
|
200
|
+
layout: z
|
|
201
|
+
.string()
|
|
202
|
+
.regex(/^[\w-]+$/, "layout must be a file name without extension")
|
|
203
|
+
.optional()
|
|
204
|
+
.describe("Layout for tag pages (src/layouts/<layout>.ts). Defaults to the default layout."),
|
|
205
|
+
})
|
|
206
|
+
.default({ pages: true }),
|
|
197
207
|
markdown: z
|
|
198
208
|
.strictObject({
|
|
199
209
|
html: z
|
|
@@ -204,22 +214,31 @@ export const configSchema = z.strictObject({
|
|
|
204
214
|
.boolean()
|
|
205
215
|
.default(false)
|
|
206
216
|
.describe('Keep style="..." attributes in page content (sanitize mode). Off by default: inline styles are the usual way to hide spam links. Put styling in layouts.'),
|
|
217
|
+
headingIds: z
|
|
218
|
+
.boolean()
|
|
219
|
+
.default(true)
|
|
220
|
+
.describe('Give each ## and ### heading in page content an id made from its text ("Quick start" -> id="quick-start"), so sections can be linked. Ids you write yourself are kept.'),
|
|
207
221
|
})
|
|
208
|
-
.default({ html: "sanitize", inlineStyles: false }),
|
|
222
|
+
.default({ html: "sanitize", inlineStyles: false, headingIds: true }),
|
|
209
223
|
seo: seoSchema.default(() => seoSchema.parse({})),
|
|
210
224
|
performance: performanceSchema.default(() => performanceSchema.parse({})),
|
|
211
225
|
deploy: z
|
|
212
226
|
.strictObject({
|
|
213
227
|
target: z
|
|
214
|
-
.enum(["github-pages", "cloudflare-pages", "cloudflare-workers"])
|
|
228
|
+
.enum(["github-pages", "cloudflare-pages", "cloudflare-workers", "netlify"])
|
|
215
229
|
.default("github-pages")
|
|
216
|
-
.describe("Host used by `vage deploy`. Cloudflare now creates new sites as Workers: prefer cloudflare-workers there."),
|
|
230
|
+
.describe("Host used by `vage deploy`. Cloudflare now creates new sites as Workers: prefer cloudflare-workers there. netlify also makes the build write Netlify's _redirects and _headers."),
|
|
217
231
|
cloudflareProject: z.string().regex(/^[a-z0-9][a-z0-9-]{0,57}$/).optional().describe("Cloudflare Pages project name."),
|
|
218
232
|
cloudflareWorker: z
|
|
219
233
|
.string()
|
|
220
234
|
.regex(/^[a-z0-9][a-z0-9-]{0,62}$/)
|
|
221
235
|
.optional()
|
|
222
236
|
.describe("Cloudflare Worker name (cloudflare-workers target, when the project has no wrangler config). Defaults to cloudflareProject."),
|
|
237
|
+
netlifySite: z
|
|
238
|
+
.string()
|
|
239
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,62}$/)
|
|
240
|
+
.optional()
|
|
241
|
+
.describe("Netlify site name or ID (netlify target). Create the site in Netlify first."),
|
|
223
242
|
})
|
|
224
243
|
.default({ target: "github-pages" }),
|
|
225
244
|
security: z
|
package/dist/core/dates.d.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The publish date of a page that isn't due yet, else undefined. A date ("2026-11-01")
|
|
3
|
+
* is due from that day on the build machine's calendar (UTC on most CI); a datetime
|
|
4
|
+
* with an offset ("2026-11-01T09:00:00+05:30") from that moment.
|
|
5
|
+
*/
|
|
6
|
+
export declare function scheduledFor(datePublished: string | undefined, now?: Date): string | undefined;
|
|
1
7
|
/** A frontmatter date as a point in time ("2026-10-24" = midnight UTC); NaN when missing. */
|
|
2
8
|
export declare function instant(date: string | undefined): number;
|
package/dist/core/dates.js
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The publish date of a page that isn't due yet, else undefined. A date ("2026-11-01")
|
|
3
|
+
* is due from that day on the build machine's calendar (UTC on most CI); a datetime
|
|
4
|
+
* with an offset ("2026-11-01T09:00:00+05:30") from that moment.
|
|
5
|
+
*/
|
|
6
|
+
export function scheduledFor(datePublished, now = new Date()) {
|
|
7
|
+
if (!datePublished)
|
|
8
|
+
return undefined;
|
|
9
|
+
if (datePublished.length === 10) {
|
|
10
|
+
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
11
|
+
return datePublished > today ? datePublished : undefined;
|
|
12
|
+
}
|
|
13
|
+
return Date.parse(datePublished) > now.getTime() ? datePublished : undefined;
|
|
14
|
+
}
|
|
1
15
|
/** A frontmatter date as a point in time ("2026-10-24" = midnight UTC); NaN when missing. */
|
|
2
16
|
export function instant(date) {
|
|
3
17
|
return date ? Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date) : NaN;
|
package/dist/core/pages.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface PageSummary {
|
|
|
15
15
|
frontmatter: Frontmatter;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
* Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
|
|
18
|
+
* Every published page (drafts, pages scheduled for a later date and 404.md excluded), sorted by route. Reads frontmatter
|
|
19
19
|
* and applies each page's layout schema, so list entries get the same defaults as the
|
|
20
20
|
* page itself. The result is frozen: layouts share it, so none may change it.
|
|
21
21
|
* `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
|
|
@@ -35,6 +35,8 @@ export interface CollectionOptions {
|
|
|
35
35
|
*/
|
|
36
36
|
sort?: "date" | "title" | "route" | "order" | ((a: PageSummary, b: PageSummary) => number);
|
|
37
37
|
limit?: number;
|
|
38
|
+
/** Only pages with this tag (compared like tag page addresses: "SEO" matches "seo"). */
|
|
39
|
+
tag?: string;
|
|
38
40
|
}
|
|
39
41
|
/**
|
|
40
42
|
* Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
|
|
@@ -43,6 +45,18 @@ export interface CollectionOptions {
|
|
|
43
45
|
/** By `order` (lowest first; pages without one last), then title. */
|
|
44
46
|
export declare function byOrder(a: PageSummary, b: PageSummary): number;
|
|
45
47
|
export declare function collection(pages: readonly PageSummary[], options?: CollectionOptions): PageSummary[];
|
|
48
|
+
/** A tag's address part: "Web Design" -> "web-design" (tag pages live at /tags/<slug>/). */
|
|
49
|
+
export declare function tagSlug(tag: string): string;
|
|
50
|
+
export interface TagSummary {
|
|
51
|
+
/** The tag as first written in the pages, e.g. "SEO". */
|
|
52
|
+
name: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
/** "/tags/<slug>/" (generated when tags.pages is on). */
|
|
55
|
+
route: string;
|
|
56
|
+
count: number;
|
|
57
|
+
}
|
|
58
|
+
/** Every tag the pages use, A to Z, with how many pages have it: for tag clouds and tag menus. */
|
|
59
|
+
export declare function tagList(pages: readonly PageSummary[]): TagSummary[];
|
|
46
60
|
/** The pages before and after `route` in an ordered list (e.g. from collection()), for "Previous / Next" links. */
|
|
47
61
|
export declare function neighbors(list: readonly PageSummary[], route: string): {
|
|
48
62
|
prev?: PageSummary;
|
package/dist/core/pages.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseFrontmatter } from "../content/markdown.js";
|
|
4
|
+
import { headingSlug } from "../content/sanitize.js";
|
|
4
5
|
import { pageMeta } from "../seo/head.js";
|
|
5
6
|
import { clonePlain } from "./clone.js";
|
|
6
|
-
import { instant } from "./dates.js";
|
|
7
|
+
import { instant, scheduledFor } from "./dates.js";
|
|
7
8
|
import { applyLayoutFields } from "./render.js";
|
|
8
9
|
import { VageError } from "./errors.js";
|
|
9
10
|
import { discoverRoutes } from "./routes.js";
|
|
10
11
|
/**
|
|
11
|
-
* Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
|
|
12
|
+
* Every published page (drafts, pages scheduled for a later date and 404.md excluded), sorted by route. Reads frontmatter
|
|
12
13
|
* and applies each page's layout schema, so list entries get the same defaults as the
|
|
13
14
|
* page itself. The result is frozen: layouts share it, so none may change it.
|
|
14
15
|
* `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
|
|
@@ -21,7 +22,7 @@ export async function collectPages(root, site, load, options = {}) {
|
|
|
21
22
|
let frontmatter;
|
|
22
23
|
try {
|
|
23
24
|
({ frontmatter } = parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file));
|
|
24
|
-
if (frontmatter.draft)
|
|
25
|
+
if (frontmatter.draft || scheduledFor(frontmatter.datePublished))
|
|
25
26
|
continue;
|
|
26
27
|
await applyLayoutFields(root, frontmatter, load, entry.file);
|
|
27
28
|
}
|
|
@@ -70,7 +71,9 @@ export function byOrder(a, b) {
|
|
|
70
71
|
export function collection(pages, options = {}) {
|
|
71
72
|
if (!Array.isArray(pages))
|
|
72
73
|
throw new VageError("collection(): pass the layout's `pages` as the first argument.");
|
|
73
|
-
const { under, includeIndex = false, sort = "route", limit } = options;
|
|
74
|
+
const { under, includeIndex = false, sort = "route", limit, tag } = options;
|
|
75
|
+
if (tag !== undefined && (typeof tag !== "string" || !tagSlug(tag)))
|
|
76
|
+
throw new VageError(`collection(): "tag" must be a tag name like "SEO" (got ${JSON.stringify(tag)}).`);
|
|
74
77
|
if (under !== undefined && !/^\/(?!\/)/.test(under)) {
|
|
75
78
|
throw new VageError(`collection(): "under" must be a site path starting with "/", like "/blog/" (got ${JSON.stringify(under)}).`);
|
|
76
79
|
}
|
|
@@ -83,7 +86,7 @@ export function collection(pages, options = {}) {
|
|
|
83
86
|
throw new VageError(`collection(): "limit" must be a whole number of 0 or more (got ${JSON.stringify(limit)}).`);
|
|
84
87
|
}
|
|
85
88
|
const prefix = under ? (under.endsWith("/") ? under : `${under}/`) : undefined;
|
|
86
|
-
const picked = pages.filter((p) => !prefix || (p.route.startsWith(prefix) && (includeIndex || p.route !== prefix)));
|
|
89
|
+
const picked = pages.filter((p) => (!prefix || (p.route.startsWith(prefix) && (includeIndex || p.route !== prefix))) && (tag === undefined || (p.frontmatter.tags ?? []).some((t) => tagSlug(t) === tagSlug(tag))));
|
|
87
90
|
const byTitle = (a, b) => a.title.localeCompare(b.title);
|
|
88
91
|
const compare = typeof sort === "function"
|
|
89
92
|
? sort
|
|
@@ -103,6 +106,25 @@ export function collection(pages, options = {}) {
|
|
|
103
106
|
const sorted = [...picked].sort(compare);
|
|
104
107
|
return limit === undefined ? sorted : sorted.slice(0, limit);
|
|
105
108
|
}
|
|
109
|
+
/** A tag's address part: "Web Design" -> "web-design" (tag pages live at /tags/<slug>/). */
|
|
110
|
+
export function tagSlug(tag) {
|
|
111
|
+
return headingSlug(tag);
|
|
112
|
+
}
|
|
113
|
+
/** Every tag the pages use, A to Z, with how many pages have it: for tag clouds and tag menus. */
|
|
114
|
+
export function tagList(pages) {
|
|
115
|
+
const tags = new Map();
|
|
116
|
+
for (const p of pages) {
|
|
117
|
+
for (const name of new Set(p.frontmatter.tags ?? [])) {
|
|
118
|
+
const slug = tagSlug(name);
|
|
119
|
+
if (!slug)
|
|
120
|
+
continue;
|
|
121
|
+
const t = tags.get(slug) ?? { name, slug, route: `/tags/${slug}/`, count: 0 };
|
|
122
|
+
t.count++;
|
|
123
|
+
tags.set(slug, t);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [...tags.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
127
|
+
}
|
|
106
128
|
/** The pages before and after `route` in an ordered list (e.g. from collection()), for "Previous / Next" links. */
|
|
107
129
|
export function neighbors(list, route) {
|
|
108
130
|
const i = list.findIndex((p) => p.route === route);
|
package/dist/core/render.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export declare function renderPage(options: {
|
|
|
49
49
|
pages?: readonly PageSummary[];
|
|
50
50
|
/** Resizer for images.optimize (tests inject one; sharp otherwise). */
|
|
51
51
|
resize?: Resize;
|
|
52
|
+
/** The page's Markdown, for generated pages (tag pages); otherwise `file` is read. */
|
|
53
|
+
source?: string;
|
|
52
54
|
}): Promise<RenderedPage>;
|
|
53
55
|
/**
|
|
54
56
|
* Loads the page's layout and, if it exports a `frontmatter` schema, validates the page
|
package/dist/core/render.js
CHANGED
|
@@ -11,7 +11,7 @@ import { headTags, injectHead, pageMeta } from "../seo/head.js";
|
|
|
11
11
|
import { analyticsTags, injectAnalytics } from "../integrations/tags.js";
|
|
12
12
|
export async function renderPage(options) {
|
|
13
13
|
const { root, file, route, site, load } = options;
|
|
14
|
-
const parsed = parseMarkdown(fs.readFileSync(file, "utf8"), file);
|
|
14
|
+
const parsed = parseMarkdown(options.source ?? fs.readFileSync(file, "utf8"), file);
|
|
15
15
|
const { frontmatter, body } = parsed;
|
|
16
16
|
const publicDir = path.join(root, "public");
|
|
17
17
|
const assets = new Map();
|
|
@@ -29,8 +29,8 @@ export async function renderPage(options) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
const content = site.markdown.html === "allow"
|
|
32
|
-
? await enhanceTrustedHtml(parsed.html, publicDir, variants)
|
|
33
|
-
: await sanitizeHtml(parsed.html, { publicDir, inlineStyles: site.markdown.inlineStyles, variants });
|
|
32
|
+
? await enhanceTrustedHtml(parsed.html, publicDir, variants, site.markdown.headingIds)
|
|
33
|
+
: await sanitizeHtml(parsed.html, { publicDir, inlineStyles: site.markdown.inlineStyles, variants, headingIds: site.markdown.headingIds });
|
|
34
34
|
const layout = await applyLayoutFields(root, frontmatter, load, file);
|
|
35
35
|
const meta = pageMeta(site, route, frontmatter, options.notFound);
|
|
36
36
|
const result = await layout({
|
package/dist/core/site.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ModuleLoader, type SiteConfig } from "./config.js";
|
|
2
2
|
import { type RenderedPage } from "./render.js";
|
|
3
|
+
import { type PageSummary } from "./pages.js";
|
|
3
4
|
export interface SitePage extends RenderedPage {
|
|
4
5
|
route: string;
|
|
5
6
|
/** Source path relative to the project root. */
|
|
@@ -28,6 +29,12 @@ export interface SiteModel {
|
|
|
28
29
|
skippedDrafts: string[];
|
|
29
30
|
/** Routes of draft pages; links to them are broken in production. */
|
|
30
31
|
draftRoutes: string[];
|
|
32
|
+
/** Pages whose datePublished is still ahead: skipped until a build on or after that date. */
|
|
33
|
+
skippedScheduled: {
|
|
34
|
+
source: string;
|
|
35
|
+
route: string;
|
|
36
|
+
date: string;
|
|
37
|
+
}[];
|
|
31
38
|
notFound?: SitePage;
|
|
32
39
|
redirects: SiteRedirect[];
|
|
33
40
|
/** Generated discovery files: output path -> content. */
|
|
@@ -38,5 +45,7 @@ export interface SiteModel {
|
|
|
38
45
|
export declare function renderSite(root: string, load: ModuleLoader, options?: {
|
|
39
46
|
collectErrors?: boolean;
|
|
40
47
|
}): Promise<SiteModel>;
|
|
48
|
+
/** 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. */
|
|
49
|
+
export declare function tagPageSource(site: SiteConfig, name: string, tagged: PageSummary[]): string;
|
|
41
50
|
/** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
|
|
42
51
|
export declare function listedPages(model: SiteModel): SitePage[];
|
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/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";
|
|
@@ -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";
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Published pages and
|
|
1
|
+
/** Published pages, drafts and scheduled pages, without writing anything. */
|
|
2
2
|
export declare function listPages(root: string): Promise<{
|
|
3
3
|
pages: {
|
|
4
4
|
route: string;
|
|
@@ -9,6 +9,10 @@ export declare function listPages(root: string): Promise<{
|
|
|
9
9
|
noindex: boolean;
|
|
10
10
|
}[];
|
|
11
11
|
drafts: string[];
|
|
12
|
+
scheduled: {
|
|
13
|
+
source: string;
|
|
14
|
+
date: string;
|
|
15
|
+
}[];
|
|
12
16
|
errors: import("../core/site.js").PageError[];
|
|
13
17
|
}>;
|
|
14
18
|
/**
|
package/dist/mcp/server.js
CHANGED
|
@@ -24,7 +24,7 @@ async function loadSdk(root) {
|
|
|
24
24
|
}
|
|
25
25
|
throw new VageError(INSTALL);
|
|
26
26
|
}
|
|
27
|
-
/** Published pages and
|
|
27
|
+
/** Published pages, drafts and scheduled pages, without writing anything. */
|
|
28
28
|
export async function listPages(root) {
|
|
29
29
|
const loader = await createBuildLoader(root);
|
|
30
30
|
try {
|
|
@@ -39,6 +39,7 @@ export async function listPages(root) {
|
|
|
39
39
|
noindex: Boolean(p.meta.noindex),
|
|
40
40
|
})),
|
|
41
41
|
drafts: model.skippedDrafts,
|
|
42
|
+
scheduled: model.skippedScheduled.map(({ source, date }) => ({ source, date })),
|
|
42
43
|
errors: model.pageErrors,
|
|
43
44
|
};
|
|
44
45
|
}
|
|
@@ -74,7 +75,7 @@ export async function startMcpServer(root, options) {
|
|
|
74
75
|
});
|
|
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()));
|
|
76
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)));
|
|
77
|
-
server.registerTool("vage_pages", { title: "List pages", description: "Every published page (route, source file, title, description, layout, noindex), the drafts that are skipped, and pages that fail to render.", annotations: READ_ONLY }, tool(() => listPages(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)));
|
|
78
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)));
|
|
79
80
|
await server.connect(options.transport ?? new sdk.StdioServerTransport());
|
|
80
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",
|
|
@@ -16,6 +16,15 @@ 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.
|
|
@@ -33,6 +42,9 @@ Run `npm run check` after every change and fix all errors before finishing.
|
|
|
33
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.
|
|
34
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).
|
|
35
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.
|
|
36
48
|
- **Rename a page:** move the file, then add `redirects: { "/old-slug/": "/new-slug/" }` to `vage.config.ts`.
|
|
37
49
|
- **Hide a page from search:** `noindex: true` (it stays published). To unpublish, use `draft: true` or delete it.
|
|
38
50
|
- **AI crawler policy:** set `seo.robots.aiTraining` / `aiSearch` only on the owner's instruction.
|
|
@@ -66,10 +78,11 @@ canonical: /other/ # canonical override (path or absolute URL); page then
|
|
|
66
78
|
image: /images/og.png # og:image (made absolute using `site`)
|
|
67
79
|
type: article # og:type + Article JSON-LD (default: website)
|
|
68
80
|
author: Jane Doe # Article author
|
|
69
|
-
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
|
|
70
82
|
dateModified: 2026-02-15 # also the sitemap <lastmod> (otherwise datePublished, or git with seo.lastmod: "git")
|
|
71
83
|
order: 2 # position in menus built with tree() or sort: "order" (lower first)
|
|
72
84
|
navTitle: Pricing # shorter label for menus and breadcrumbs (PageSummary.label)
|
|
85
|
+
tags: [SEO, AI] # each tag gets a page at /tags/<tag>/
|
|
73
86
|
---
|
|
74
87
|
```
|
|
75
88
|
|
|
@@ -90,7 +103,7 @@ Hacked sites are usually abused for hidden spam links and doorway pages. vagejs
|
|
|
90
103
|
|
|
91
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.
|
|
92
105
|
|
|
93
|
-
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`.
|
|
94
107
|
|
|
95
108
|
## Layouts
|
|
96
109
|
|
|
@@ -115,14 +128,14 @@ From the published pages and config (never from `public/`; a hand-written `publi
|
|
|
115
128
|
- `sitemap.xml`: indexable, self-canonical, non-draft pages. Needs `site`.
|
|
116
129
|
- `llms.txt` (+ `llms-full.txt` with `llmsTxt: { full: true }`): same pages. An emerging convention; not a guarantee that AI tools use it.
|
|
117
130
|
- Redirects: `redirects: { "/old/": "/new/" }` become noindex meta-refresh pages (dev server answers 301).
|
|
118
|
-
- 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/`.
|
|
119
132
|
|
|
120
133
|
|
|
121
134
|
## Config
|
|
122
135
|
|
|
123
136
|
`vage.config.ts` keys: `site` (origin only, no path), `title`, `description`, `lang`,
|
|
124
|
-
`seo: { robots, sitemap, llmsTxt, structuredData, feeds, markdownCopies, lastmod }`, `redirects`, `markdown`,
|
|
125
|
-
`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 }`,
|
|
126
139
|
`security: { headers, csp, cspSources, outboundLinks, spamPatterns }`, `integrations`, `guidelines`.
|
|
127
140
|
Keys not in this list are an error; `npx vage info --json` has the full schema.
|
|
128
141
|
|
|
@@ -142,13 +155,14 @@ Always ask the site owner before publishing or deploying. Show them the plan fir
|
|
|
142
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`).
|
|
143
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.
|
|
144
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`.
|
|
145
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.
|
|
146
160
|
|
|
147
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.
|
|
148
162
|
|
|
149
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.
|
|
150
164
|
|
|
151
|
-
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.
|
|
152
166
|
`.github/workflows/ci.yml` runs check, build and audit on every push and PR, and uploads `dist/` as a downloadable preview artifact.
|
|
153
167
|
|
|
154
168
|
## Analytics and search reports
|
|
@@ -168,7 +182,7 @@ Off unless configured in `vage.config.ts` under `integrations`. Ask the site own
|
|
|
168
182
|
|
|
169
183
|
## Not implemented yet
|
|
170
184
|
|
|
171
|
-
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.
|
|
172
186
|
|
|
173
187
|
## Favicon
|
|
174
188
|
|