vagejs 0.2.0 → 0.3.1

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.
Files changed (47) hide show
  1. package/README.md +9 -3
  2. package/dist/checks/crawlability.d.ts +34 -0
  3. package/dist/checks/crawlability.js +142 -0
  4. package/dist/checks/verify.d.ts +8 -1
  5. package/dist/checks/verify.js +108 -11
  6. package/dist/cli/index.js +6 -4
  7. package/dist/cli/info.d.ts +11 -0
  8. package/dist/cli/info.js +20 -5
  9. package/dist/content/images.d.ts +4 -1
  10. package/dist/content/images.js +36 -3
  11. package/dist/content/markdown.d.ts +2 -0
  12. package/dist/content/markdown.js +2 -0
  13. package/dist/content/optimize.d.ts +31 -0
  14. package/dist/content/optimize.js +92 -0
  15. package/dist/content/sanitize.d.ts +8 -1
  16. package/dist/content/sanitize.js +3 -3
  17. package/dist/core/build.js +11 -0
  18. package/dist/core/config.d.ts +17 -0
  19. package/dist/core/config.js +24 -2
  20. package/dist/core/dates.d.ts +2 -0
  21. package/dist/core/dates.js +4 -0
  22. package/dist/core/pages.d.ts +28 -1
  23. package/dist/core/pages.js +86 -11
  24. package/dist/core/render.d.ts +7 -0
  25. package/dist/core/render.js +19 -2
  26. package/dist/core/site.js +50 -9
  27. package/dist/deploy/github-pages.js +2 -0
  28. package/dist/deploy/headers.d.ts +13 -2
  29. package/dist/deploy/headers.js +15 -3
  30. package/dist/dev/guard.d.ts +2 -0
  31. package/dist/dev/guard.js +30 -1
  32. package/dist/dev/server.js +19 -3
  33. package/dist/discovery/feed.d.ts +21 -0
  34. package/dist/discovery/feed.js +68 -0
  35. package/dist/discovery/git-dates.d.ts +10 -0
  36. package/dist/discovery/git-dates.js +57 -0
  37. package/dist/discovery/markdown-copy.d.ts +23 -0
  38. package/dist/discovery/markdown-copy.js +152 -0
  39. package/dist/guidelines/rule-pack.json +133 -4
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.js +1 -1
  42. package/dist/seo/head.js +16 -0
  43. package/package.json +4 -1
  44. package/templates/starter/.github/workflows/ci.yml +2 -0
  45. package/templates/starter/.github/workflows/verify.yml +2 -0
  46. package/templates/starter/AGENTS.md +15 -7
  47. package/templates/starter/package.json +1 -1
package/dist/core/site.js CHANGED
@@ -1,9 +1,12 @@
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 { copySource, 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
- import { headersFile } from "../deploy/headers.js";
9
+ import { copyCanonicalsFit, headersFile } from "../deploy/headers.js";
7
10
  import { cloudflareRedirects } from "../deploy/redirects.js";
8
11
  import { findSecrets } from "../deploy/secrets.js";
9
12
  import { rulePack } from "../guidelines/pack.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(await copySource(p.html, 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
- generate("sitemap.xml", () => sitemapXml(listed.map((p) => ({ url: p.meta.canonical, lastmod: p.frontmatter.dateModified ?? p.frontmatter.datePublished }))));
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)
@@ -105,7 +140,12 @@ function addDiscoveryFiles(root, model) {
105
140
  generate("_redirects", () => cloudflareRedirects(model.redirects));
106
141
  if (cloudflare && site.security.headers) {
107
142
  const html = [...model.pages, ...(model.notFound ? [model.notFound] : []), ...model.redirects].map((p) => p.html);
108
- generate("_headers", () => headersFile(html, site));
143
+ // Each Markdown copy names its HTML page as canonical, so search engines don't index both.
144
+ 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)) : [];
145
+ if (!copyCanonicalsFit(copies.length)) {
146
+ warnings.push(`_headers: ${copies.length} Markdown copies need more header rules than Cloudflare allows, so they're published without a canonical Link header.`);
147
+ }
148
+ generate("_headers", () => headersFile(html, site, copies));
109
149
  }
110
150
  if (site.seo.llmsTxt) {
111
151
  if (!site.title) {
@@ -114,9 +154,10 @@ function addDiscoveryFiles(root, model) {
114
154
  else {
115
155
  const pages = listed.map((p) => ({
116
156
  title: p.frontmatter.title,
117
- url: absoluteUrl(site.site, p.route) ?? p.route,
157
+ // With Markdown copies, AI tools are pointed at the plain-text version (llmstxt.org).
158
+ url: site.seo.markdownCopies ? (absoluteUrl(site.site, `/${markdownCopyPath(p.route)}`) ?? `/${markdownCopyPath(p.route)}`) : (absoluteUrl(site.site, p.route) ?? p.route),
118
159
  description: p.frontmatter.description,
119
- body: p.body,
160
+ body: markdown.get(p.route) ?? "",
120
161
  }));
121
162
  generate("llms.txt", () => llmsTxt(site.title, site.description, pages));
122
163
  if (site.seo.llmsTxt.full)
@@ -25,6 +25,8 @@ jobs:
25
25
  contents: read
26
26
  steps:
27
27
  - uses: actions/checkout@v7
28
+ with:
29
+ fetch-depth: 0 # full history, so seo.lastmod: "git" can date pages
28
30
  - uses: actions/setup-node@v7
29
31
  with:
30
32
  node-version: 22
@@ -7,5 +7,16 @@ import type { SiteConfig } from "../core/config.js";
7
7
  * any https origin so ordinary sites don't break.
8
8
  */
9
9
  export declare function contentSecurityPolicy(pagesHtml: string[], site: SiteConfig): string;
10
- /** Cloudflare Pages / Netlify `_headers` for every path. */
11
- export declare function headersFile(pagesHtml: string[], site: SiteConfig): string;
10
+ /**
11
+ * Markdown copies can name their HTML page as canonical with one rule each, while the
12
+ * file stays within Cloudflare's rule limit (the first rule covers every path).
13
+ */
14
+ export declare const copyCanonicalsFit: (copies: number) => boolean;
15
+ /**
16
+ * Cloudflare Pages / Netlify `_headers` for every path, plus a canonical Link header on
17
+ * each Markdown copy (`copies`: its path and its page's URL) when they fit.
18
+ */
19
+ export declare function headersFile(pagesHtml: string[], site: SiteConfig, copies?: {
20
+ path: string;
21
+ canonical: string;
22
+ }[]): string;
@@ -3,7 +3,7 @@ import { parse } from "node-html-parser";
3
3
  import { VageError } from "../core/errors.js";
4
4
  import { ruleData } from "../guidelines/pack.js";
5
5
  // https://developers.cloudflare.com/pages/configuration/headers/ (rule pack "cloudflare.headers", "csp.providers")
6
- const { maxLineLength } = ruleData("cloudflare.headers");
6
+ const { maxLineLength, maxRules } = ruleData("cloudflare.headers");
7
7
  const PROVIDERS = ruleData("csp.providers");
8
8
  const EXECUTABLE = /^(|module|text\/javascript|application\/javascript)$/i;
9
9
  /**
@@ -87,8 +87,16 @@ export function contentSecurityPolicy(pagesHtml, site) {
87
87
  ];
88
88
  return directives.join("; ");
89
89
  }
90
- /** Cloudflare Pages / Netlify `_headers` for every path. */
91
- export function headersFile(pagesHtml, site) {
90
+ /**
91
+ * Markdown copies can name their HTML page as canonical with one rule each, while the
92
+ * file stays within Cloudflare's rule limit (the first rule covers every path).
93
+ */
94
+ export const copyCanonicalsFit = (copies) => copies + 1 <= maxRules;
95
+ /**
96
+ * Cloudflare Pages / Netlify `_headers` for every path, plus a canonical Link header on
97
+ * each Markdown copy (`copies`: its path and its page's URL) when they fit.
98
+ */
99
+ export function headersFile(pagesHtml, site, copies = []) {
92
100
  const lines = ["# Generated by vagejs (security in vage.config.ts)", "/*", " X-Content-Type-Options: nosniff", " Referrer-Policy: strict-origin-when-cross-origin", " X-Frame-Options: SAMEORIGIN", " Permissions-Policy: camera=(), microphone=(), geolocation=()"];
93
101
  if (site.security.csp !== "off") {
94
102
  const name = site.security.csp === "report-only" ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";
@@ -99,5 +107,9 @@ export function headersFile(pagesHtml, site) {
99
107
  throw new VageError(`The generated Content-Security-Policy is ${tooLong.length} characters; _headers lines are limited to ${maxLineLength}. ` +
100
108
  "Reduce distinct inline scripts/styles (move them to files in public/), or set security.csp to \"off\".");
101
109
  }
110
+ if (copyCanonicalsFit(copies.length)) {
111
+ for (const c of copies)
112
+ lines.push(c.path, ` Link: <${c.canonical}>; rel="canonical"`);
113
+ }
102
114
  return `${lines.join("\n")}\n`;
103
115
  }
@@ -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
- return fs.lstatSync(file).isFile(); // lstat: symlinks are not served
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;
@@ -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
- if (DISCOVERY_FILES[pathname]) {
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
- res.setHeader("Content-Type", DISCOVERY_FILES[pathname]);
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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" };
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,23 @@
1
+ /** "/" -> "index.md", "/docs/cli/" -> "docs/cli.md". */
2
+ export declare function markdownCopyPath(route: string): string;
3
+ /**
4
+ * The part of a rendered page the copy is made from: the inside of its one `<main>`, so
5
+ * content a layout builds from frontmatter (FAQ answers, post lists) is included. Pages
6
+ * without exactly one `<main>` fall back to the Markdown body.
7
+ */
8
+ export declare function copySource(pageHtml: string, content: string): Promise<string>;
9
+ /**
10
+ * The page as Markdown, converted from its rendered, sanitized HTML: the copy contains
11
+ * exactly what the page shows (no comments, no raw HTML the sanitizer removed, the same
12
+ * links the link lock checked), with links and images made absolute.
13
+ */
14
+ export declare function htmlToMarkdown(html: string, options: {
15
+ route: string;
16
+ origin?: string;
17
+ }): Promise<string>;
18
+ /**
19
+ * The copy: the page's own H1 (or the title), the summary, then the content. The H1 is
20
+ * moved to the top even when a layout puts something before it, but never taken from
21
+ * inside a code block.
22
+ */
23
+ export declare function markdownCopy(title: string, description: string | undefined, markdown: string): string;
@@ -0,0 +1,152 @@
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 part of a rendered page the copy is made from: the inside of its one `<main>`, so
63
+ * content a layout builds from frontmatter (FAQ answers, post lists) is included. Pages
64
+ * without exactly one `<main>` fall back to the Markdown body.
65
+ */
66
+ export async function copySource(pageHtml, content) {
67
+ jsdom ??= await import("jsdom");
68
+ // Parsed as HTML (scripts don't run), so "<main>" text in scripts, comments or attributes doesn't count.
69
+ const mains = new jsdom.JSDOM(pageHtml).window.document.querySelectorAll("main");
70
+ return mains.length === 1 ? mains[0].innerHTML : content;
71
+ }
72
+ /**
73
+ * The page as Markdown, converted from its rendered, sanitized HTML: the copy contains
74
+ * exactly what the page shows (no comments, no raw HTML the sanitizer removed, the same
75
+ * links the link lock checked), with links and images made absolute.
76
+ */
77
+ export async function htmlToMarkdown(html, options) {
78
+ jsdom ??= await import("jsdom");
79
+ const doc = new jsdom.JSDOM("").window.document;
80
+ const template = doc.createElement("template");
81
+ template.innerHTML = html;
82
+ const base = `https://site.invalid${options.route}`;
83
+ const absolute = (value) => {
84
+ if (/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value))
85
+ return value;
86
+ try {
87
+ const u = new URL(value, base);
88
+ return `${options.origin ?? ""}${u.pathname}${u.search}${u.hash}`;
89
+ }
90
+ catch {
91
+ return value;
92
+ }
93
+ };
94
+ // Hidden content isn't on the page for visitors, so it isn't in the copy either.
95
+ // Layouts mark repeated extras (share links, calls to action) with data-vage-copy="skip";
96
+ // aria-hidden parts are decoration that screen readers skip too.
97
+ for (const el of template.content.querySelectorAll('[hidden], svg, math, [aria-hidden="true"], [data-vage-copy="skip"]'))
98
+ el.remove();
99
+ // A heading split over two lines with <br> stays one Markdown heading.
100
+ for (const br of template.content.querySelectorAll("h1 br, h2 br, h3 br, h4 br, h5 br, h6 br"))
101
+ br.replaceWith(" ");
102
+ for (const a of template.content.querySelectorAll("a[href]")) {
103
+ const href = a.getAttribute("href").trim();
104
+ // Script and data URLs never become links in the copy (trusted HTML can contain them).
105
+ if (/^(javascript|vbscript|data):/i.test(href.replace(/[\s\u0000-\u001f]/g, "")))
106
+ a.removeAttribute("href");
107
+ else
108
+ a.setAttribute("href", absolute(href));
109
+ }
110
+ for (const img of template.content.querySelectorAll("img[src]")) {
111
+ const src = img.getAttribute("src").trim();
112
+ if (/^(javascript|vbscript|data):/i.test(src.replace(/[\s\u0000-\u001f]/g, "")))
113
+ img.remove();
114
+ else
115
+ img.setAttribute("src", absolute(src));
116
+ img.removeAttribute("srcset");
117
+ }
118
+ const wrapper = doc.createElement("div");
119
+ wrapper.append(template.content);
120
+ return converter().turndown(wrapper).trim();
121
+ }
122
+ /**
123
+ * The copy: the page's own H1 (or the title), the summary, then the content. The H1 is
124
+ * moved to the top even when a layout puts something before it, but never taken from
125
+ * inside a code block.
126
+ */
127
+ export function markdownCopy(title, description, markdown) {
128
+ const lines = markdown.split("\n");
129
+ let fence = "";
130
+ let at = -1;
131
+ for (let i = 0; i < lines.length; i++) {
132
+ const line = lines[i];
133
+ const run = /^(`{3,}|~{3,})/.exec(line)?.[1];
134
+ if (fence) {
135
+ if (run && run[0] === fence[0] && run.length >= fence.length && !line.slice(run.length).trim())
136
+ fence = "";
137
+ }
138
+ else if (run) {
139
+ fence = run;
140
+ }
141
+ else if (/^#[ \t]+\S/.test(line)) {
142
+ at = i;
143
+ break;
144
+ }
145
+ }
146
+ const heading = `# ${at >= 0 ? lines[at].replace(/^#[ \t]+/, "").trim() : title}`;
147
+ let next = at + 1;
148
+ while (at >= 0 && next < lines.length && !lines[next].trim())
149
+ next++; // and the blank lines after it
150
+ const rest = at >= 0 ? [...lines.slice(0, at), ...lines.slice(next)].join("\n") : markdown;
151
+ return `${heading}\n\n${description ? `> ${description}\n\n` : ""}${rest.trim()}\n`;
152
+ }