From f22795056da645e7d585308ce9aad083945efe21 Mon Sep 17 00:00:00 2001 From: SebaSOFT Date: Mon, 28 Sep 2026 17:16:26 +0000 Subject: [PATCH] feat(docs): llms.txt v2 markdown mirrors and link relations - scripts/copy-md-mirrors.mjs: copies every docs page as a clean .md twin into dist, served at the same URL path. Runs after vitepress build, so the sitemap (generated during build) stays clean of mirrors by construction. - transformHead in the VitePress config injects per-page rel="alternate" type="text/markdown" pointing at each page's own mirror, plus rel="describedby" pointing at llms.txt, per the llms.txt v2 pattern (same-URL markdown versions for agents). - docs:build pipeline now runs the mirror copy step. - Contract test md-mirrors-v2.test.ts: pipeline wiring, head links, skip rules. Verified: 131 mirrors copied, 0 .md URLs in sitemap.xml, alternate/ describedby links present in nested pages and homepage, suite 126/126. --- docs/.vitepress/config.ts | 11 ++++++++ package.json | 2 +- scripts/copy-md-mirrors.mjs | 36 +++++++++++++++++++++++++++ tests/contracts/md-mirrors-v2.test.ts | 30 ++++++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 scripts/copy-md-mirrors.mjs create mode 100644 tests/contracts/md-mirrors-v2.test.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 88fbf77..4de1b46 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -16,6 +16,17 @@ export default defineConfig({ sitemap: { hostname: SITE_URL, }, + // llms.txt v2 link relations: each page declares its Markdown mirror via + // rel="alternate" type="text/markdown", and the covering llms.txt via + // rel="describedby". Mirrors are copied into dist by scripts/copy-md-mirrors.mjs. + transformHead: ({ pageData }) => { + const rel = pageData.relativePath || ''; + const mirror = rel ? `${SITE_BASE}${rel.replace(/\.md$/, '')}.md` : `${SITE_BASE}index.md`; + return [ + ['link', { rel: 'describedby', type: 'text/plain', href: `${SITE_BASE}llms.txt` }], + ['link', { rel: 'alternate', type: 'text/markdown', href: mirror }], + ]; + }, head: [ ['meta', { name: 'google-site-verification', content: '0H_0qOZVNDMTnOAHH8oitfbbeDyUGgkzzI2rLOM1YHM' }], ['meta', { property: 'og:type', content: 'website' }], diff --git a/package.json b/package.json index 2367876..5870752 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepare": "yarn build", "prepublishOnly": "yarn build", "docs:dev": "vitepress dev docs", - "docs:build": "typedoc && vitepress build docs", + "docs:build": "typedoc && vitepress build docs && node scripts/copy-md-mirrors.mjs", "docs:preview": "vitepress preview docs" }, "devDependencies": { diff --git a/scripts/copy-md-mirrors.mjs b/scripts/copy-md-mirrors.mjs new file mode 100644 index 0000000..8fc053f --- /dev/null +++ b/scripts/copy-md-mirrors.mjs @@ -0,0 +1,36 @@ +// Copies VitePress markdown sources into dist as .md mirrors, so every page +// has a clean Markdown twin served at the same URL path (llms.txt v2 pattern). +// Mirrors are excluded from sitemap.xml by construction: the sitemap is +// generated during `vitepress build`, which runs before this script. +// +// Excluded from mirroring: .vitepress config, public/ assets (already copied +// verbatim by VitePress itself), node_modules. +import { copyFileSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const DOCS_DIR = new URL("../docs", import.meta.url).pathname; +const DIST_DIR = join(DOCS_DIR, ".vitepress", "dist"); +const SKIP_DIRS = new Set([".vitepress", "public", "node_modules", "dist"]); + +const walk = (dir, base = "") => { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const rel = base ? `${base}/${entry}` : entry; + if (statSync(full).isDirectory()) { + if (SKIP_DIRS.has(entry)) continue; + out.push(...walk(full, rel)); + } else if (entry.endsWith(".md")) { + out.push(rel); + } + } + return out; +}; + +const mirrors = walk(DOCS_DIR); +for (const rel of mirrors) { + const target = join(DIST_DIR, rel); + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(join(DOCS_DIR, rel), target); +} +console.log(`md mirrors: copied ${mirrors.length} pages into dist`); diff --git a/tests/contracts/md-mirrors-v2.test.ts b/tests/contracts/md-mirrors-v2.test.ts new file mode 100644 index 0000000..bb1d232 --- /dev/null +++ b/tests/contracts/md-mirrors-v2.test.ts @@ -0,0 +1,30 @@ +// NJS-SEO-6 follow-up: contract test for llms.txt v2 markdown mirrors and +// link relations (rel="alternate" type="text/markdown" + rel="describedby"). +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const read = (p: string) => readFileSync(p, "utf8"); + +describe("llms.txt v2: markdown mirrors and link relations", () => { + it("build pipeline copies markdown mirrors into dist", () => { + const pkg = JSON.parse(read("package.json")); + expect(pkg.scripts["docs:build"]).toContain("copy-md-mirrors.mjs"); + }); + + it("transformHead injects per-page alternate and global describedby links", () => { + const config = read("docs/.vitepress/config.ts"); + expect(config).toContain('rel: \'describedby\''); + expect(config).toContain('rel: \'alternate\''); + expect(config).toContain("type: 'text/markdown'"); + // mirror path derives from pageData.relativePath so each page points at its own .md twin + expect(config).toContain("pageData.relativePath"); + }); + + it("mirror script skips sitemap, vitepress internals and public assets", () => { + const script = read("scripts/copy-md-mirrors.mjs"); + expect(script).toContain("SKIP_DIRS"); + for (const skipped of [".vitepress", "public", "node_modules", "dist"]) { + expect(script).toContain(skipped); + } + }); +});