vagejs 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/checks/crawlability.d.ts +34 -0
- package/dist/checks/crawlability.js +142 -0
- package/dist/checks/verify.d.ts +8 -1
- package/dist/checks/verify.js +108 -11
- package/dist/cli/index.js +6 -4
- package/dist/cli/info.d.ts +11 -0
- package/dist/cli/info.js +20 -5
- package/dist/content/images.d.ts +4 -1
- package/dist/content/images.js +36 -3
- package/dist/content/markdown.d.ts +2 -0
- package/dist/content/markdown.js +2 -0
- package/dist/content/optimize.d.ts +31 -0
- package/dist/content/optimize.js +92 -0
- package/dist/content/sanitize.d.ts +8 -1
- package/dist/content/sanitize.js +3 -3
- package/dist/core/build.js +11 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.js +22 -0
- package/dist/core/dates.d.ts +2 -0
- package/dist/core/dates.js +4 -0
- package/dist/core/pages.d.ts +28 -1
- package/dist/core/pages.js +86 -11
- package/dist/core/render.d.ts +7 -0
- package/dist/core/render.js +19 -2
- package/dist/core/site.js +43 -7
- package/dist/deploy/github-pages.js +2 -0
- package/dist/dev/guard.d.ts +2 -0
- package/dist/dev/guard.js +30 -1
- package/dist/dev/server.js +19 -3
- package/dist/discovery/feed.d.ts +21 -0
- package/dist/discovery/feed.js +68 -0
- package/dist/discovery/git-dates.d.ts +10 -0
- package/dist/discovery/git-dates.js +57 -0
- package/dist/discovery/markdown-copy.d.ts +13 -0
- package/dist/discovery/markdown-copy.js +112 -0
- package/dist/guidelines/rule-pack.json +100 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/seo/head.js +16 -0
- package/package.json +4 -1
- package/templates/starter/.github/workflows/ci.yml +2 -0
- package/templates/starter/.github/workflows/verify.yml +2 -0
- package/templates/starter/AGENTS.md +15 -7
- package/templates/starter/package.json +1 -1
package/dist/core/site.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { llmsFullTxt, llmsTxt } from "../discovery/llms.js";
|
|
4
|
+
import { feedPath, rssFeed } from "../discovery/feed.js";
|
|
5
|
+
import { gitPageDates } from "../discovery/git-dates.js";
|
|
6
|
+
import { htmlToMarkdown, markdownCopy, markdownCopyPath } from "../discovery/markdown-copy.js";
|
|
4
7
|
import { robotsTxt } from "../discovery/robots.js";
|
|
5
8
|
import { SITEMAP_URL_LIMIT, sitemapXml } from "../discovery/sitemap.js";
|
|
6
9
|
import { headersFile } from "../deploy/headers.js";
|
|
@@ -13,6 +16,7 @@ import { VageError } from "./errors.js";
|
|
|
13
16
|
import { escapeHtml } from "./html.js";
|
|
14
17
|
import { renderPage } from "./render.js";
|
|
15
18
|
import { parseFrontmatter } from "../content/markdown.js";
|
|
19
|
+
import { sharpResize } from "../content/optimize.js";
|
|
16
20
|
import { collectPages } from "./pages.js";
|
|
17
21
|
import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "./routes.js";
|
|
18
22
|
/** Renders every page and derives discovery files from the same data, without writing anything. */
|
|
@@ -42,6 +46,8 @@ export async function renderSite(root, load, options = {}) {
|
|
|
42
46
|
}
|
|
43
47
|
};
|
|
44
48
|
const pages = await collectPages(root, site, load, { skipInvalid: collect });
|
|
49
|
+
// Loaded once for the whole site, so a missing sharp is one clear error, not one per page.
|
|
50
|
+
const resize = site.images.optimize ? await sharpResize(root) : undefined;
|
|
45
51
|
for (const entry of discoverRoutes(pagesDir)) {
|
|
46
52
|
const source = path.relative(root, entry.file);
|
|
47
53
|
await attempt(entry.file, entry.route, async () => {
|
|
@@ -51,30 +57,38 @@ export async function renderSite(root, load, options = {}) {
|
|
|
51
57
|
model.draftRoutes.push(entry.route);
|
|
52
58
|
return;
|
|
53
59
|
}
|
|
54
|
-
const page = await renderPage({ root, file: entry.file, route: entry.route, site, load, analytics: true, pages });
|
|
60
|
+
const page = await renderPage({ root, file: entry.file, route: entry.route, site, load, analytics: true, pages, resize });
|
|
55
61
|
model.pages.push({ ...page, route: entry.route, source, outFile: entry.outFile });
|
|
56
62
|
});
|
|
57
63
|
}
|
|
58
64
|
const notFound = findNotFoundPage(pagesDir);
|
|
59
65
|
if (notFound) {
|
|
60
66
|
await attempt(notFound, undefined, async () => {
|
|
61
|
-
const page = await renderPage({ root, file: notFound, route: "/404/", site, load, notFound: true, analytics: true, pages });
|
|
67
|
+
const page = await renderPage({ root, file: notFound, route: "/404/", site, load, notFound: true, analytics: true, pages, resize });
|
|
62
68
|
model.notFound = { ...page, route: "/404/", source: path.relative(root, notFound), outFile: "404.html" };
|
|
63
69
|
});
|
|
64
70
|
}
|
|
65
71
|
checkPublicDir(root, model.warnings);
|
|
72
|
+
// Markdown versions of pages, converted from their sanitized HTML (copies and llms-full.txt).
|
|
73
|
+
const markdown = new Map();
|
|
74
|
+
if (site.seo.markdownCopies || (site.seo.llmsTxt && site.seo.llmsTxt.full)) {
|
|
75
|
+
for (const p of listedPages(model))
|
|
76
|
+
markdown.set(p.route, await htmlToMarkdown(p.content, { route: p.route, origin: site.site }));
|
|
77
|
+
}
|
|
66
78
|
model.redirects = buildRedirects(site, new Set(model.pages.map((p) => p.route)));
|
|
67
|
-
addDiscoveryFiles(root, model);
|
|
79
|
+
addDiscoveryFiles(root, model, markdown);
|
|
68
80
|
return model;
|
|
69
81
|
}
|
|
70
82
|
/** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
|
|
71
83
|
export function listedPages(model) {
|
|
72
84
|
return model.pages.filter((p) => !p.meta.noindex && p.meta.canonical === absoluteUrl(model.site.site, p.route));
|
|
73
85
|
}
|
|
74
|
-
function addDiscoveryFiles(root, model) {
|
|
86
|
+
function addDiscoveryFiles(root, model, markdown) {
|
|
75
87
|
const { site, files, warnings } = model;
|
|
76
88
|
const listed = listedPages(model);
|
|
77
89
|
const generate = (name, content) => {
|
|
90
|
+
if (files.has(name))
|
|
91
|
+
throw new VageError(`Two generated files would be written to ${name}. Rename one of the pages behind it.`);
|
|
78
92
|
if (fs.existsSync(path.join(root, "public", name))) {
|
|
79
93
|
throw new VageError(`public/${name} conflicts with the generated ${name}. Delete it, or turn generation off in vage.config.ts (seo).`);
|
|
80
94
|
}
|
|
@@ -94,7 +108,28 @@ function addDiscoveryFiles(root, model) {
|
|
|
94
108
|
if (listed.length > SITEMAP_URL_LIMIT) {
|
|
95
109
|
throw new VageError(`sitemap.xml would list ${listed.length} URLs; sitemap indexes (over ${SITEMAP_URL_LIMIT}) are not supported yet.`);
|
|
96
110
|
}
|
|
97
|
-
|
|
111
|
+
let gitDates;
|
|
112
|
+
if (site.seo.lastmod === "git") {
|
|
113
|
+
const result = gitPageDates(root);
|
|
114
|
+
gitDates = result.dates;
|
|
115
|
+
if (result.problem)
|
|
116
|
+
warnings.push(`seo.lastmod is "git", but ${result.problem}; sitemap dates come from frontmatter instead.`);
|
|
117
|
+
}
|
|
118
|
+
generate("sitemap.xml", () => sitemapXml(listed.map((p) => ({ url: p.meta.canonical, lastmod: p.frontmatter.dateModified ?? gitDates?.get(p.source) ?? p.frontmatter.datePublished }))));
|
|
119
|
+
}
|
|
120
|
+
for (const feed of site.seo.feeds) {
|
|
121
|
+
if (!site.site) {
|
|
122
|
+
warnings.push(`seo.feeds: the feed for ${feed.under} needs "site" in vage.config.ts and was skipped.`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!listed.some((p) => p.route.startsWith(feed.under) && p.route !== feed.under)) {
|
|
126
|
+
warnings.push(`seo.feeds: no published pages under ${feed.under}, so its feed is empty.`);
|
|
127
|
+
}
|
|
128
|
+
generate(feedPath(feed.under), () => rssFeed(site, feed, listed, site.site));
|
|
129
|
+
}
|
|
130
|
+
if (site.seo.markdownCopies) {
|
|
131
|
+
for (const p of listed)
|
|
132
|
+
generate(markdownCopyPath(p.route), () => markdownCopy(p.frontmatter.title, p.frontmatter.description, markdown.get(p.route) ?? ""));
|
|
98
133
|
}
|
|
99
134
|
const indexNow = site.integrations.indexNow;
|
|
100
135
|
if (indexNow)
|
|
@@ -114,9 +149,10 @@ function addDiscoveryFiles(root, model) {
|
|
|
114
149
|
else {
|
|
115
150
|
const pages = listed.map((p) => ({
|
|
116
151
|
title: p.frontmatter.title,
|
|
117
|
-
|
|
152
|
+
// With Markdown copies, AI tools are pointed at the plain-text version (llmstxt.org).
|
|
153
|
+
url: site.seo.markdownCopies ? (absoluteUrl(site.site, `/${markdownCopyPath(p.route)}`) ?? `/${markdownCopyPath(p.route)}`) : (absoluteUrl(site.site, p.route) ?? p.route),
|
|
118
154
|
description: p.frontmatter.description,
|
|
119
|
-
body: p.
|
|
155
|
+
body: markdown.get(p.route) ?? "",
|
|
120
156
|
}));
|
|
121
157
|
generate("llms.txt", () => llmsTxt(site.title, site.description, pages));
|
|
122
158
|
if (site.seo.llmsTxt.full)
|
package/dist/dev/guard.d.ts
CHANGED
|
@@ -7,3 +7,5 @@ import type { Plugin } from "vite";
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function devAccessGuard(root: string, allowedFiles: Set<string>): Plugin;
|
|
9
9
|
export declare function allowed(url: string, publicDir: string, allowedFiles: Set<string>): boolean;
|
|
10
|
+
/** A regular file whose real path (every folder resolved) is inside public/: no symlinked files or folders. */
|
|
11
|
+
export declare function insidePublic(file: string, publicDir: string): boolean;
|
package/dist/dev/guard.js
CHANGED
|
@@ -45,11 +45,40 @@ export function allowed(url, publicDir, allowedFiles) {
|
|
|
45
45
|
return true; // rendered pages (our middleware decides 200/404)
|
|
46
46
|
if (allowedFiles.has(pathname))
|
|
47
47
|
return true;
|
|
48
|
+
// Optimized images (images.optimize), served from the cache by the dev server.
|
|
49
|
+
if (/^\/_vage\/img\/[\w-]+\.webp$/.test(pathname))
|
|
50
|
+
return true;
|
|
51
|
+
// Generated files (seo.feeds, seo.markdownCopies) never exist on disk in dev. If a real file
|
|
52
|
+
// sits at that path, it's a project source (e.g. /src/pages/about.md): Vite would serve it, so refuse.
|
|
53
|
+
if (/^\/(?:[\w-]+\/)*(?:feed\.xml|[\w-]+\.md)$/.test(pathname)) {
|
|
54
|
+
if (fs.existsSync(path.join(path.dirname(publicDir), pathname)))
|
|
55
|
+
return false;
|
|
56
|
+
// A public/ file with that name is served by Vite, so apply the same rules as below.
|
|
57
|
+
const candidate = path.join(publicDir, pathname);
|
|
58
|
+
if (!fs.existsSync(candidate) && !isSymlink(candidate))
|
|
59
|
+
return true; // not in public/: generated by vagejs
|
|
60
|
+
return insidePublic(candidate, publicDir);
|
|
61
|
+
}
|
|
48
62
|
const file = path.resolve(publicDir, `.${pathname}`);
|
|
49
63
|
if (!file.startsWith(publicDir + path.sep))
|
|
50
64
|
return false;
|
|
65
|
+
return insidePublic(file, publicDir);
|
|
66
|
+
}
|
|
67
|
+
function isSymlink(file) {
|
|
68
|
+
try {
|
|
69
|
+
return fs.lstatSync(file).isSymbolicLink();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** A regular file whose real path (every folder resolved) is inside public/: no symlinked files or folders. */
|
|
76
|
+
export function insidePublic(file, publicDir) {
|
|
51
77
|
try {
|
|
52
|
-
|
|
78
|
+
if (!fs.lstatSync(file).isFile())
|
|
79
|
+
return false;
|
|
80
|
+
const realPublic = fs.realpathSync(publicDir);
|
|
81
|
+
return fs.realpathSync(file).startsWith(realPublic + path.sep);
|
|
53
82
|
}
|
|
54
83
|
catch {
|
|
55
84
|
return false;
|
package/dist/dev/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { VageError } from "../core/errors.js";
|
|
|
6
6
|
import { escapeHtml } from "../core/html.js";
|
|
7
7
|
import { baseViteConfig, viteLoader } from "../core/loader.js";
|
|
8
8
|
import { renderPage } from "../core/render.js";
|
|
9
|
+
import { cacheDir } from "../content/optimize.js";
|
|
9
10
|
import { collectPages } from "../core/pages.js";
|
|
10
11
|
import { renderSite } from "../core/site.js";
|
|
11
12
|
import { discoverRoutes, findNotFoundPage, normalizeRequestPath } from "../core/routes.js";
|
|
@@ -42,12 +43,27 @@ export async function startDevServer(root, options = {}) {
|
|
|
42
43
|
const urlPath = req.url ?? "/";
|
|
43
44
|
const pathname = urlPath.split(/[?#]/)[0];
|
|
44
45
|
try {
|
|
45
|
-
|
|
46
|
+
const optimized = /^\/_vage\/img\/([\w-]+\.webp)$/.exec(pathname);
|
|
47
|
+
if (optimized) {
|
|
48
|
+
const file = path.join(cacheDir(root), optimized[1]);
|
|
49
|
+
if (!fs.existsSync(file))
|
|
50
|
+
return send(res, 404, plainPage("404 — Not found", `No optimized image at ${pathname}`));
|
|
51
|
+
res.setHeader("Content-Type", "image/webp");
|
|
52
|
+
return res.end(fs.readFileSync(file));
|
|
53
|
+
}
|
|
54
|
+
const isFeed = /^\/(?:[\w.-]+\/)*feed\.xml$/.test(pathname) && !pathname.includes("..");
|
|
55
|
+
const isCopy = /^\/(?:[\w-]+\/)*[\w-]+\.md$/.test(pathname);
|
|
56
|
+
if (DISCOVERY_FILES[pathname] || isFeed || isCopy) {
|
|
46
57
|
// Built from the whole site (drafts excluded), exactly as `vage build` would write it.
|
|
47
58
|
const content = (await renderSite(root, load)).files.get(pathname.slice(1));
|
|
48
|
-
if (content === undefined)
|
|
59
|
+
if (content === undefined) {
|
|
60
|
+
// Feeds and Markdown copies exist only as generated files; never fall through to Vite,
|
|
61
|
+
// which would serve project sources (e.g. /src/pages/about.md).
|
|
62
|
+
if (isFeed || isCopy)
|
|
63
|
+
return send(res, 404, plainPage("404 — Not found", `No generated file at ${pathname}`));
|
|
49
64
|
return next();
|
|
50
|
-
|
|
65
|
+
}
|
|
66
|
+
res.setHeader("Content-Type", DISCOVERY_FILES[pathname] ?? (isCopy ? "text/markdown; charset=utf-8" : "application/rss+xml; charset=utf-8"));
|
|
51
67
|
return res.end(content);
|
|
52
68
|
}
|
|
53
69
|
// Leave asset requests (any extension other than .html) to Vite.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SiteConfig } from "../core/config.js";
|
|
2
|
+
import type { SitePage } from "../core/site.js";
|
|
3
|
+
export interface FeedConfig {
|
|
4
|
+
under: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
limit: number;
|
|
8
|
+
}
|
|
9
|
+
/** The feed's title: configured, else the site title. Used for the channel and the page links. */
|
|
10
|
+
export declare function feedTitle(site: SiteConfig, feed: {
|
|
11
|
+
title?: string;
|
|
12
|
+
under: string;
|
|
13
|
+
}): string;
|
|
14
|
+
/** Where a section's feed is published: "/blog/" -> "blog/feed.xml". */
|
|
15
|
+
export declare function feedPath(under: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* RSS 2.0 for one section: its listed pages, newest datePublished first (undated last).
|
|
18
|
+
* Item descriptions are the page descriptions; dates come only from frontmatter, so the
|
|
19
|
+
* feed is the same on every build.
|
|
20
|
+
*/
|
|
21
|
+
export declare function rssFeed(site: SiteConfig, feed: FeedConfig, pages: SitePage[], origin: string): string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { instant } from "../core/dates.js";
|
|
2
|
+
const XML_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
3
|
+
const xml = (s) => s.replace(/[&<>"']/g, (c) => XML_ESCAPES[c]);
|
|
4
|
+
/** The feed's title: configured, else the site title. Used for the channel and the page links. */
|
|
5
|
+
export function feedTitle(site, feed) {
|
|
6
|
+
return feed.title ?? site.title ?? feed.under;
|
|
7
|
+
}
|
|
8
|
+
/** Where a section's feed is published: "/blog/" -> "blog/feed.xml". */
|
|
9
|
+
export function feedPath(under) {
|
|
10
|
+
return `${under.replace(/^\/+/, "")}feed.xml`;
|
|
11
|
+
}
|
|
12
|
+
/** RFC 822 date, as RSS requires: "Thu, 23 Oct 2026 00:00:00 GMT". */
|
|
13
|
+
function rfc822(iso) {
|
|
14
|
+
return new Date(iso.length === 10 ? `${iso}T00:00:00Z` : iso).toUTCString();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* RSS 2.0 for one section: its listed pages, newest datePublished first (undated last).
|
|
18
|
+
* Item descriptions are the page descriptions; dates come only from frontmatter, so the
|
|
19
|
+
* feed is the same on every build.
|
|
20
|
+
*/
|
|
21
|
+
export function rssFeed(site, feed, pages, origin) {
|
|
22
|
+
const items = pages
|
|
23
|
+
.filter((p) => p.route.startsWith(feed.under) && p.route !== feed.under)
|
|
24
|
+
.sort((a, b) => {
|
|
25
|
+
// Compare as instants: "2026-10-23T23:00:00-05:00" is after "2026-10-24".
|
|
26
|
+
const ta = instant(a.frontmatter.datePublished);
|
|
27
|
+
const tb = instant(b.frontmatter.datePublished);
|
|
28
|
+
if (ta === tb || (Number.isNaN(ta) && Number.isNaN(tb)))
|
|
29
|
+
return a.frontmatter.title.localeCompare(b.frontmatter.title);
|
|
30
|
+
return Number.isNaN(ta) ? 1 : Number.isNaN(tb) ? -1 : tb - ta;
|
|
31
|
+
})
|
|
32
|
+
.slice(0, feed.limit);
|
|
33
|
+
const section = pages.find((p) => p.route === feed.under);
|
|
34
|
+
// Same default as the <link rel="alternate"> title on the pages.
|
|
35
|
+
const title = feedTitle(site, feed);
|
|
36
|
+
const description = feed.description ?? section?.frontmatter.description ?? site.description ?? title;
|
|
37
|
+
const self = `${origin}/${feedPath(feed.under)}`;
|
|
38
|
+
const newest = items.find((p) => p.frontmatter.datePublished)?.frontmatter.datePublished; // items are sorted newest first
|
|
39
|
+
const body = items
|
|
40
|
+
.map((p) => {
|
|
41
|
+
const link = p.meta.canonical ?? `${origin}${p.route}`;
|
|
42
|
+
const date = p.frontmatter.datePublished;
|
|
43
|
+
return [
|
|
44
|
+
" <item>",
|
|
45
|
+
` <title>${xml(p.frontmatter.title)}</title>`,
|
|
46
|
+
` <link>${xml(link)}</link>`,
|
|
47
|
+
` <guid isPermaLink="true">${xml(link)}</guid>`,
|
|
48
|
+
p.frontmatter.description ? ` <description>${xml(p.frontmatter.description)}</description>` : "",
|
|
49
|
+
date ? ` <pubDate>${rfc822(date)}</pubDate>` : "",
|
|
50
|
+
" </item>",
|
|
51
|
+
]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join("\n");
|
|
54
|
+
})
|
|
55
|
+
.join("\n");
|
|
56
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
57
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
58
|
+
<channel>
|
|
59
|
+
<title>${xml(title)}</title>
|
|
60
|
+
<link>${xml(`${origin}${feed.under}`)}</link>
|
|
61
|
+
<description>${xml(description)}</description>
|
|
62
|
+
<language>${xml(site.lang)}</language>
|
|
63
|
+
<atom:link href="${xml(self)}" rel="self" type="application/rss+xml" />${newest ? `\n <lastBuildDate>${rfc822(newest)}</lastBuildDate>` : ""}
|
|
64
|
+
${body}
|
|
65
|
+
</channel>
|
|
66
|
+
</rss>
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last commit date (ISO) of every file under src/pages, from one `git log` call.
|
|
3
|
+
* Returns undefined with a reason when dates would be wrong or unavailable: not a git
|
|
4
|
+
* repository, or a shallow clone (CI's default), where every file seems to change in
|
|
5
|
+
* the latest commit.
|
|
6
|
+
*/
|
|
7
|
+
export declare function gitPageDates(root: string): {
|
|
8
|
+
dates?: Map<string, string>;
|
|
9
|
+
problem?: string;
|
|
10
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Last commit date (ISO) of every file under src/pages, from one `git log` call.
|
|
6
|
+
* Returns undefined with a reason when dates would be wrong or unavailable: not a git
|
|
7
|
+
* repository, or a shallow clone (CI's default), where every file seems to change in
|
|
8
|
+
* the latest commit.
|
|
9
|
+
*/
|
|
10
|
+
export function gitPageDates(root) {
|
|
11
|
+
// core.quotepath=off: print non-ASCII file names as-is (default would quote "caf\303\251.md").
|
|
12
|
+
const git = (args) => execFileSync("git", ["-c", "core.quotepath=off", ...args], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
|
|
13
|
+
try {
|
|
14
|
+
git(["--version"]);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return { problem: "git isn't available" };
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
if (git(["rev-parse", "--is-inside-work-tree"]).trim() !== "true")
|
|
21
|
+
return { problem: "the project isn't a git repository" };
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { problem: "the project isn't a git repository" };
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
if (git(["rev-parse", "--is-shallow-repository"]).trim() === "true") {
|
|
28
|
+
return { problem: "this is a shallow git clone, so every page would get the latest commit's date (in GitHub Actions, use actions/checkout with fetch-depth: 0)" };
|
|
29
|
+
}
|
|
30
|
+
const top = git(["rev-parse", "--show-toplevel"]).trim();
|
|
31
|
+
// git prints real paths; compare against the project's real path (e.g. /var -> /private/var on macOS).
|
|
32
|
+
const real = fs.realpathSync(root);
|
|
33
|
+
// -z: NUL-separated, so file names with quotes, spaces or newlines come through exactly.
|
|
34
|
+
const out = git(["log", "-z", "--format=@@%cI", "--name-only", "--no-renames", "--", path.join(root, "src", "pages")]);
|
|
35
|
+
const dates = new Map();
|
|
36
|
+
let current = "";
|
|
37
|
+
for (const raw of out.split("\0")) {
|
|
38
|
+
const field = raw.replace(/^\n+/, "");
|
|
39
|
+
if (!field)
|
|
40
|
+
continue;
|
|
41
|
+
const header = /^@@(\S+)\n?([\s\S]*)$/.exec(field);
|
|
42
|
+
const name = header ? header[2] : field;
|
|
43
|
+
if (header)
|
|
44
|
+
current = header[1];
|
|
45
|
+
if (name && current) {
|
|
46
|
+
const rel = path.relative(real, path.join(top, name));
|
|
47
|
+
if (!dates.has(rel))
|
|
48
|
+
dates.set(rel, current); // newest first: keep the first date seen
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { dates };
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const stderr = String(err.stderr ?? "").trim().split("\n")[0];
|
|
55
|
+
return { problem: `reading the git history failed (${stderr || err.message.split("\n")[0]})` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** "/" -> "index.md", "/docs/cli/" -> "docs/cli.md". */
|
|
2
|
+
export declare function markdownCopyPath(route: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The page as Markdown, converted from its rendered, sanitized HTML: the copy contains
|
|
5
|
+
* exactly what the page shows (no comments, no raw HTML the sanitizer removed, the same
|
|
6
|
+
* links the link lock checked), with links and images made absolute.
|
|
7
|
+
*/
|
|
8
|
+
export declare function htmlToMarkdown(html: string, options: {
|
|
9
|
+
route: string;
|
|
10
|
+
origin?: string;
|
|
11
|
+
}): Promise<string>;
|
|
12
|
+
/** The copy: the page's own H1 (or the title), the summary, then the content. */
|
|
13
|
+
export declare function markdownCopy(title: string, description: string | undefined, markdown: string): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import TurndownService from "turndown";
|
|
2
|
+
/** "/" -> "index.md", "/docs/cli/" -> "docs/cli.md". */
|
|
3
|
+
export function markdownCopyPath(route) {
|
|
4
|
+
return route === "/" ? "index.md" : `${route.slice(1, -1)}.md`;
|
|
5
|
+
}
|
|
6
|
+
let jsdom;
|
|
7
|
+
let service;
|
|
8
|
+
function converter() {
|
|
9
|
+
if (service)
|
|
10
|
+
return service;
|
|
11
|
+
const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-", emDelimiter: "_" });
|
|
12
|
+
// Never carry executable or embedded content into the copy, even from trusted HTML.
|
|
13
|
+
td.remove(["script", "style", "noscript", "template", "iframe", "object", "embed", "form", "button", "select", "textarea"]); // svg and math are removed from the DOM first
|
|
14
|
+
// "<" in text must stay text: a Markdown viewer that renders HTML would otherwise run it.
|
|
15
|
+
const baseEscape = td.escape.bind(td);
|
|
16
|
+
td.escape = (text) => baseEscape(text).replace(/</g, "\\<");
|
|
17
|
+
td.addRule("checkbox", {
|
|
18
|
+
filter: (node) => node.nodeName === "INPUT",
|
|
19
|
+
replacement: (_c, node) => (node.type === "checkbox" ? (node.checked || node.hasAttribute("checked") ? "[x] " : "[ ] ") : ""),
|
|
20
|
+
});
|
|
21
|
+
td.addRule("strikethrough", { filter: ["del", "s"], replacement: (content) => (content.trim() ? `~~${content}~~` : "") });
|
|
22
|
+
td.addRule("preformatted", {
|
|
23
|
+
filter: (node) => node.nodeName === "PRE" && !node.querySelector("code"),
|
|
24
|
+
replacement: (_c, node) => {
|
|
25
|
+
const text = (node.textContent ?? "").replace(/\n$/, "");
|
|
26
|
+
// The fence must be longer than any backtick run inside, or the text could close it early.
|
|
27
|
+
const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
|
28
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
29
|
+
return `\n\n${fence}\n${text}\n${fence}\n\n`;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
// A link nobody can see or click on the page shouldn't appear in the copy.
|
|
33
|
+
td.addRule("emptyLink", {
|
|
34
|
+
filter: (node) => node.nodeName === "A" && !(node.textContent ?? "").trim() && !node.querySelector("img[alt]:not([alt=''])"),
|
|
35
|
+
replacement: () => "",
|
|
36
|
+
});
|
|
37
|
+
// Simple GFM tables, built in one pass: cells and rows leave separators that the table
|
|
38
|
+
// rule turns into pipes, so each level of a nested table is converted only once.
|
|
39
|
+
const CELL = "\u0001";
|
|
40
|
+
const ROW = "\u0002";
|
|
41
|
+
const cell = (s) => s.replace(/[\u0001\u0002]/g, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
|
|
42
|
+
td.addRule("tableCell", { filter: ["th", "td"], replacement: (content) => `${cell(content)}${CELL}` });
|
|
43
|
+
td.addRule("tableRow", { filter: "tr", replacement: (content) => `${ROW}${content}` });
|
|
44
|
+
td.addRule("table", {
|
|
45
|
+
filter: "table",
|
|
46
|
+
replacement: (content) => {
|
|
47
|
+
const rows = content
|
|
48
|
+
.split(ROW)
|
|
49
|
+
.map((r) => r.split(CELL).slice(0, -1).map((c) => c.trim()))
|
|
50
|
+
.filter((r) => r.length);
|
|
51
|
+
if (!rows.length)
|
|
52
|
+
return "";
|
|
53
|
+
const width = Math.max(...rows.map((r) => r.length));
|
|
54
|
+
const line = (r) => `| ${Array.from({ length: width }, (_, i) => r[i] ?? "").join(" | ")} |`;
|
|
55
|
+
return `\n\n${[line(rows[0]), line(Array(width).fill("---")), ...rows.slice(1).map(line)].join("\n")}\n\n`;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
service = td;
|
|
59
|
+
return td;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The page as Markdown, converted from its rendered, sanitized HTML: the copy contains
|
|
63
|
+
* exactly what the page shows (no comments, no raw HTML the sanitizer removed, the same
|
|
64
|
+
* links the link lock checked), with links and images made absolute.
|
|
65
|
+
*/
|
|
66
|
+
export async function htmlToMarkdown(html, options) {
|
|
67
|
+
jsdom ??= await import("jsdom");
|
|
68
|
+
const doc = new jsdom.JSDOM("").window.document;
|
|
69
|
+
const template = doc.createElement("template");
|
|
70
|
+
template.innerHTML = html;
|
|
71
|
+
const base = `https://site.invalid${options.route}`;
|
|
72
|
+
const absolute = (value) => {
|
|
73
|
+
if (/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value))
|
|
74
|
+
return value;
|
|
75
|
+
try {
|
|
76
|
+
const u = new URL(value, base);
|
|
77
|
+
return `${options.origin ?? ""}${u.pathname}${u.search}${u.hash}`;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
// Hidden content isn't on the page for visitors, so it isn't in the copy either.
|
|
84
|
+
for (const el of template.content.querySelectorAll("[hidden], svg, math"))
|
|
85
|
+
el.remove();
|
|
86
|
+
for (const a of template.content.querySelectorAll("a[href]")) {
|
|
87
|
+
const href = a.getAttribute("href").trim();
|
|
88
|
+
// Script and data URLs never become links in the copy (trusted HTML can contain them).
|
|
89
|
+
if (/^(javascript|vbscript|data):/i.test(href.replace(/[\s\u0000-\u001f]/g, "")))
|
|
90
|
+
a.removeAttribute("href");
|
|
91
|
+
else
|
|
92
|
+
a.setAttribute("href", absolute(href));
|
|
93
|
+
}
|
|
94
|
+
for (const img of template.content.querySelectorAll("img[src]")) {
|
|
95
|
+
const src = img.getAttribute("src").trim();
|
|
96
|
+
if (/^(javascript|vbscript|data):/i.test(src.replace(/[\s\u0000-\u001f]/g, "")))
|
|
97
|
+
img.remove();
|
|
98
|
+
else
|
|
99
|
+
img.setAttribute("src", absolute(src));
|
|
100
|
+
img.removeAttribute("srcset");
|
|
101
|
+
}
|
|
102
|
+
const wrapper = doc.createElement("div");
|
|
103
|
+
wrapper.append(template.content);
|
|
104
|
+
return converter().turndown(wrapper).trim();
|
|
105
|
+
}
|
|
106
|
+
/** The copy: the page's own H1 (or the title), the summary, then the content. */
|
|
107
|
+
export function markdownCopy(title, description, markdown) {
|
|
108
|
+
const own = /^#\s+(.+?)\s*(?:\n+|$)/.exec(markdown);
|
|
109
|
+
const heading = `# ${own ? own[1] : title}`;
|
|
110
|
+
const rest = own ? markdown.slice(own[0].length) : markdown;
|
|
111
|
+
return `${heading}\n\n${description ? `> ${description}\n\n` : ""}${rest.trim()}\n`;
|
|
112
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2026.10.
|
|
2
|
+
"version": "2026.10.1",
|
|
3
3
|
"released": "2026-10-23",
|
|
4
4
|
"sources": [
|
|
5
5
|
{
|
|
@@ -266,6 +266,14 @@
|
|
|
266
266
|
"url": "https://developers.cloudflare.com/pages/configuration/headers/",
|
|
267
267
|
"category": "hosting",
|
|
268
268
|
"cadence": "weekly"
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"id": "perplexity-bots",
|
|
272
|
+
"provider": "Perplexity",
|
|
273
|
+
"title": "Perplexity Crawlers",
|
|
274
|
+
"url": "https://docs.perplexity.ai/guides/bots",
|
|
275
|
+
"category": "ai-crawlers",
|
|
276
|
+
"cadence": "weekly"
|
|
269
277
|
}
|
|
270
278
|
],
|
|
271
279
|
"rules": [
|
|
@@ -597,6 +605,90 @@
|
|
|
597
605
|
"data": {
|
|
598
606
|
"maxLineLength": 2000
|
|
599
607
|
}
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
"id": "crawlers.probes",
|
|
611
|
+
"summary": "Crawlers `vage verify` impersonates to test that a live site lets search and AI crawlers in. Full user-agent strings only where we could copy them verbatim from the provider's page (W.X.Y.Z = current Chrome version); otherwise the documented token, which is what CDN bot rules match. A user-agent probe can't detect blocks based on the real crawlers' IP addresses.",
|
|
612
|
+
"behaviorChanging": true,
|
|
613
|
+
"sources": [
|
|
614
|
+
"google-crawlers",
|
|
615
|
+
"bing-guidelines",
|
|
616
|
+
"openai-bots",
|
|
617
|
+
"anthropic-crawlers",
|
|
618
|
+
"perplexity-bots"
|
|
619
|
+
],
|
|
620
|
+
"implementedIn": [
|
|
621
|
+
"src/checks/crawlability.ts"
|
|
622
|
+
],
|
|
623
|
+
"data": {
|
|
624
|
+
"crawlers": [
|
|
625
|
+
{
|
|
626
|
+
"token": "Googlebot",
|
|
627
|
+
"provider": "Google",
|
|
628
|
+
"purpose": "search",
|
|
629
|
+
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
|
630
|
+
"source": "google-crawlers"
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
"token": "bingbot",
|
|
634
|
+
"provider": "Microsoft",
|
|
635
|
+
"purpose": "search",
|
|
636
|
+
"source": "bing-guidelines"
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
"token": "OAI-SearchBot",
|
|
640
|
+
"provider": "OpenAI",
|
|
641
|
+
"purpose": "ai-search",
|
|
642
|
+
"source": "openai-bots"
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
"token": "GPTBot",
|
|
646
|
+
"provider": "OpenAI",
|
|
647
|
+
"purpose": "ai-training",
|
|
648
|
+
"userAgent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.4; +https://openai.com/gptbot",
|
|
649
|
+
"source": "openai-bots"
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
"token": "ChatGPT-User",
|
|
653
|
+
"provider": "OpenAI",
|
|
654
|
+
"purpose": "ai-user",
|
|
655
|
+
"userAgent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot",
|
|
656
|
+
"source": "openai-bots"
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
"token": "Claude-SearchBot",
|
|
660
|
+
"provider": "Anthropic",
|
|
661
|
+
"purpose": "ai-search",
|
|
662
|
+
"source": "anthropic-crawlers"
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
"token": "ClaudeBot",
|
|
666
|
+
"provider": "Anthropic",
|
|
667
|
+
"purpose": "ai-training",
|
|
668
|
+
"source": "anthropic-crawlers"
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
"token": "Claude-User",
|
|
672
|
+
"provider": "Anthropic",
|
|
673
|
+
"purpose": "ai-user",
|
|
674
|
+
"source": "anthropic-crawlers"
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
"token": "PerplexityBot",
|
|
678
|
+
"provider": "Perplexity",
|
|
679
|
+
"purpose": "ai-search",
|
|
680
|
+
"userAgent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
|
|
681
|
+
"source": "perplexity-bots"
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
"token": "Perplexity-User",
|
|
685
|
+
"provider": "Perplexity",
|
|
686
|
+
"purpose": "ai-user",
|
|
687
|
+
"userAgent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)",
|
|
688
|
+
"source": "perplexity-bots"
|
|
689
|
+
}
|
|
690
|
+
]
|
|
691
|
+
}
|
|
600
692
|
}
|
|
601
693
|
],
|
|
602
694
|
"changelog": [
|
|
@@ -606,6 +698,13 @@
|
|
|
606
698
|
"summary": "Initial rule pack (unreleased; extended during Phase 8 and the security review with IndexNow, Clarity, Plausible, Cloudflare Pages and CSP provider rules). Facts checked against the cited sources on 2026-10-23 during implementation.",
|
|
607
699
|
"reviewedBy": "Venkatesh A G E",
|
|
608
700
|
"contentHash": "8c7272511f733c01984ce1d962b39e2d521a88b31a5c958a95307a04b82bb3dd"
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
"version": "2026.10.1",
|
|
704
|
+
"date": "2026-10-23",
|
|
705
|
+
"summary": "Add crawlers.probes (search and AI crawler user agents and tokens for vage verify's crawlability check) and the Perplexity crawler source. Full user-agent strings copied verbatim on 2026-10-23 for Googlebot, GPTBot, ChatGPT-User, PerplexityBot and Perplexity-User; OAI-SearchBot, Anthropic's crawlers and bingbot are recorded by token (Anthropic's page lists only tokens, Bing's page couldn't be read, and OAI-SearchBot's full string couldn't be copied verbatim). No existing rule changed.",
|
|
706
|
+
"reviewedBy": "Venkatesh A G E",
|
|
707
|
+
"contentHash": "4d88418eb0b5425da970a1a30218a097783e117ce65376171d9a7f3ab7d9f658"
|
|
609
708
|
}
|
|
610
709
|
]
|
|
611
710
|
}
|
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 { collection, type CollectionOptions, type PageSummary } from "./core/pages.js";
|
|
7
|
+
export { breadcrumbs, collection, flatten, neighbors, tree, type CollectionOptions, type PageSummary, 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 { collection } from "./core/pages.js";
|
|
5
|
+
export { breadcrumbs, collection, flatten, neighbors, 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";
|