diff --git a/scripts/update-wiki/main.ts b/scripts/update-wiki/main.ts index 79bb2d26..c010378f 100644 --- a/scripts/update-wiki/main.ts +++ b/scripts/update-wiki/main.ts @@ -4,335 +4,336 @@ import { Glob } from "bun"; import { md2mdx } from "./api-md2mdx"; type ImplDoc = { - /** Directory path relative to this repo, e.g. "internal/health/check" */ - pkgPath: string; - /** File name in wiki `src/impl/`, e.g. "internal-health-check.md" */ - docFileName: string; - /** VitePress route path (extensionless), e.g. "/impl/internal-health-check" */ - docRoute: string; - /** Absolute source README path */ - srcPathAbs: string; - /** Absolute destination doc path */ - dstPathAbs: string; + /** Directory path relative to this repo, e.g. "internal/health/check" */ + pkgPath: string; + /** File name in wiki `src/impl/`, e.g. "internal-health-check.md" */ + docFileName: string; + /** VitePress route path (extensionless), e.g. "/impl/internal-health-check" */ + docRoute: string; + /** Absolute source README path */ + srcPathAbs: string; + /** Absolute destination doc path */ + dstPathAbs: string; }; const skipSubmodules = [ - "internal/go-oidc/", - "internal/gopsutil/", - "internal/go-proxmox/", + "internal/go-oidc/", + "internal/gopsutil/", + "internal/go-proxmox/", ]; function normalizeRepoUrl(raw: string) { - let url = (raw ?? "").trim(); - if (!url) return ""; - // Common typo: "https://https://github.com/..." - url = url.replace(/^https?:\/\/https?:\/\//i, "https://"); - if (!/^https?:\/\//i.test(url)) url = `https://${url}`; - url = url.replace(/\/+$/, ""); - return url; + let url = (raw ?? "").trim(); + if (!url) return ""; + // Common typo: "https://https://github.com/..." + url = url.replace(/^https?:\/\/https?:\/\//i, "https://"); + if (!/^https?:\/\//i.test(url)) url = `https://${url}`; + url = url.replace(/\/+$/, ""); + return url; } function sanitizeFileStemFromPkgPath(pkgPath: string) { - // Convert a package path into a stable filename. - // Example: "internal/go-oidc/example" -> "internal-go-oidc-example" - // Keep it readable and unique (uses full path). - const parts = pkgPath - .split("/") - .filter(Boolean) - .map((p) => p.replace(/[^A-Za-z0-9._-]+/g, "-")); - const joined = parts.join("-"); - return joined.replace(/-+/g, "-").replace(/^-|-$/g, ""); + // Convert a package path into a stable filename. + // Example: "internal/go-oidc/example" -> "internal-go-oidc-example" + // Keep it readable and unique (uses full path). + const parts = pkgPath + .split("/") + .filter(Boolean) + .map((p) => p.replace(/[^A-Za-z0-9._-]+/g, "-")); + const joined = parts.join("-"); + return joined.replace(/-+/g, "-").replace(/^-|-$/g, ""); } function splitUrlAndFragment(url: string): { - urlNoFragment: string; - fragment: string; + urlNoFragment: string; + fragment: string; } { - const i = url.indexOf("#"); - if (i === -1) return { urlNoFragment: url, fragment: "" }; - return { urlNoFragment: url.slice(0, i), fragment: url.slice(i) }; + const i = url.indexOf("#"); + if (i === -1) return { urlNoFragment: url, fragment: "" }; + return { urlNoFragment: url.slice(0, i), fragment: url.slice(i) }; } function isExternalOrAbsoluteUrl(url: string) { - // - absolute site links: "/foo" - // - pure fragments: "#bar" - // - external schemes: "https:", "mailto:", "vscode:", etc. - // IMPORTANT: don't treat "config.go:29" as a scheme. - if (url.startsWith("/") || url.startsWith("#")) return true; - if (url.includes("://")) return true; - return /^(https?|mailto|tel|vscode|file|data|ssh|git):/i.test(url); + // - absolute site links: "/foo" + // - pure fragments: "#bar" + // - external schemes: "https:", "mailto:", "vscode:", etc. + // IMPORTANT: don't treat "config.go:29" as a scheme. + if (url.startsWith("/") || url.startsWith("#")) return true; + if (url.includes("://")) return true; + return /^(https?|mailto|tel|vscode|file|data|ssh|git):/i.test(url); } function isRepoSourceFilePath(filePath: string) { - // Conservative allow-list: avoid rewriting .md (non-README) which may be VitePress docs. - return /\.(go|ts|tsx|js|jsx|py|sh|yml|yaml|json|toml|env|css|html|txt)$/i.test( - filePath, - ); + // Conservative allow-list: avoid rewriting .md (non-README) which may be VitePress docs. + return /\.(go|ts|tsx|js|jsx|py|sh|yml|yaml|json|toml|env|css|html|txt)$/i.test( + filePath, + ); } function parseFileLineSuffix(urlNoFragment: string): { - filePath: string; - line?: string; + filePath: string; + line?: string; } { - // Match "file.ext:123" (line suffix), while leaving "file.ext" untouched. - const m = urlNoFragment.match(/^(.*?):(\d+)$/); - if (!m) return { filePath: urlNoFragment }; - return { filePath: m[1] ?? urlNoFragment, line: m[2] }; + // Match "file.ext:123" (line suffix), while leaving "file.ext" untouched. + const m = urlNoFragment.match(/^(.*?):(\d+)$/); + if (!m) return { filePath: urlNoFragment }; + return { filePath: m[1] ?? urlNoFragment, line: m[2] }; } function rewriteMarkdownLinksOutsideFences( - md: string, - rewriteInline: (url: string) => string, + md: string, + rewriteInline: (url: string) => string, ) { - const lines = md.split("\n"); - let inFence = false; + const lines = md.split("\n"); + let inFence = false; - for (let i = 0; i < lines.length; i++) { - const line = lines[i] ?? ""; - const trimmed = line.trimStart(); - if (trimmed.startsWith("```")) { - inFence = !inFence; - continue; - } - if (inFence) continue; + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const trimmed = line.trimStart(); + if (trimmed.startsWith("```")) { + inFence = !inFence; + continue; + } + if (inFence) continue; - // Inline markdown links/images: [text](url "title") / ![alt](url) - lines[i] = line.replace( - /\]\(([^)\s]+)(\s+"[^"]*")?\)/g, - (_full, urlRaw: string, maybeTitle: string | undefined) => { - const rewritten = rewriteInline(urlRaw); - return `](${rewritten}${maybeTitle ?? ""})`; - }, - ); - } + // Inline markdown links/images: [text](url "title") / ![alt](url) + lines[i] = line.replace( + /\]\(([^)\s]+)(\s+"[^"]*")?\)/g, + (_full, urlRaw: string, maybeTitle: string | undefined) => { + const rewritten = rewriteInline(urlRaw); + return `](${rewritten}${maybeTitle ?? ""})`; + }, + ); + } - return lines.join("\n"); + return lines.join("\n"); } function rewriteImplMarkdown(params: { - md: string; - pkgPath: string; - readmeRelToDocRoute: Map; - dirPathToDocRoute: Map; - repoUrl: string; + md: string; + pkgPath: string; + readmeRelToDocRoute: Map; + dirPathToDocRoute: Map; + repoUrl: string; }) { - const { md, pkgPath, readmeRelToDocRoute, dirPathToDocRoute, repoUrl } = - params; + const { md, pkgPath, readmeRelToDocRoute, dirPathToDocRoute, repoUrl } = + params; - return rewriteMarkdownLinksOutsideFences(md, (urlRaw) => { - // Handle angle-bracketed destinations: (<./foo/README.md>) - const angleWrapped = - urlRaw.startsWith("<") && urlRaw.endsWith(">") - ? urlRaw.slice(1, -1) - : urlRaw; + return rewriteMarkdownLinksOutsideFences(md, (urlRaw) => { + // Handle angle-bracketed destinations: (<./foo/README.md>) + const angleWrapped = + urlRaw.startsWith("<") && urlRaw.endsWith(">") + ? urlRaw.slice(1, -1) + : urlRaw; - const { urlNoFragment, fragment } = splitUrlAndFragment(angleWrapped); - if (!urlNoFragment) return urlRaw; - if (isExternalOrAbsoluteUrl(urlNoFragment)) return urlRaw; + const { urlNoFragment, fragment } = splitUrlAndFragment(angleWrapped); + if (!urlNoFragment) return urlRaw; + if (isExternalOrAbsoluteUrl(urlNoFragment)) return urlRaw; - // 1) Directory links like "common" or "common/" that have a README - const dirPathNormalized = urlNoFragment.replace(/\/+$/, ""); - let rewritten: string | undefined; - // First try exact match - if (dirPathToDocRoute.has(dirPathNormalized)) { - rewritten = `${dirPathToDocRoute.get(dirPathNormalized)}${fragment}`; - } else { - // Fallback: check parent directories for a README - // This handles paths like "internal/watcher/events" where only the parent has a README - let parentPath = dirPathNormalized; - while (parentPath.includes("/")) { - parentPath = parentPath.slice(0, parentPath.lastIndexOf("/")); - if (dirPathToDocRoute.has(parentPath)) { - rewritten = `${dirPathToDocRoute.get(parentPath)}${fragment}`; - break; - } - } - } - if (rewritten) { - return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`; - } + // 1) Directory links like "common" or "common/" that have a README + const dirPathNormalized = urlNoFragment.replace(/\/+$/, ""); + let rewritten: string | undefined; + // First try exact match + if (dirPathToDocRoute.has(dirPathNormalized)) { + rewritten = `${dirPathToDocRoute.get(dirPathNormalized)}${fragment}`; + } else { + // Fallback: check parent directories for a README + // This handles paths like "internal/watcher/events" where only the parent has a README + let parentPath = dirPathNormalized; + while (parentPath.includes("/")) { + parentPath = parentPath.slice(0, parentPath.lastIndexOf("/")); + if (dirPathToDocRoute.has(parentPath)) { + rewritten = `${dirPathToDocRoute.get(parentPath)}${fragment}`; + break; + } + } + } + if (rewritten) { + return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`; + } - // 2) Intra-repo README links -> VitePress impl routes - if (/(^|\/)README\.md$/.test(urlNoFragment)) { - const targetReadmeRel = path.posix.normalize( - path.posix.join(pkgPath, urlNoFragment), - ); - const route = readmeRelToDocRoute.get(targetReadmeRel); - if (route) { - const rewritten = `${route}${fragment}`; - return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`; - } - return urlRaw; - } + // 2) Intra-repo README links -> VitePress impl routes + if (/(^|\/)README\.md$/.test(urlNoFragment)) { + const targetReadmeRel = path.posix.normalize( + path.posix.join(pkgPath, urlNoFragment), + ); + const route = readmeRelToDocRoute.get(targetReadmeRel); + if (route) { + const rewritten = `${route}${fragment}`; + return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`; + } + return urlRaw; + } - // 3) Local source-file references like "config.go:29" -> GitHub blob link - if (repoUrl) { - const { filePath, line } = parseFileLineSuffix(urlNoFragment); - if (isRepoSourceFilePath(filePath)) { - const repoRel = path.posix.normalize( - path.posix.join(pkgPath, filePath), - ); - const githubUrl = `${repoUrl}/blob/main/${repoRel}${ - line ? `#L${line}` : "" - }`; - const rewritten = `${githubUrl}${fragment}`; - return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`; - } - } + // 3) Local source-file references like "config.go:29" -> GitHub blob link + if (repoUrl) { + const { filePath, line } = parseFileLineSuffix(urlNoFragment); + if (isRepoSourceFilePath(filePath)) { + const repoRel = path.posix.normalize( + path.posix.join(pkgPath, filePath), + ); + const githubUrl = `${repoUrl}/blob/main/${repoRel}${ + line ? `#L${line}` : "" + }`; + const rewritten = `${githubUrl}${fragment}`; + return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`; + } + } - return urlRaw; - }); + return urlRaw; + }); } async function listRepoReadmes(repoRootAbs: string): Promise { - const glob = new Glob("**/README.md"); - const readmes: string[] = []; + const glob = new Glob("**/README.md"); + const readmes: string[] = []; - for await (const rel of glob.scan({ - cwd: repoRootAbs, - onlyFiles: true, - dot: false, - })) { - // Bun returns POSIX-style rel paths. - if (rel === "README.md") continue; // exclude root README - if (rel.startsWith(".git/") || rel.includes("/.git/")) continue; - if (rel.startsWith("node_modules/") || rel.includes("/node_modules/")) - continue; - let skip = false; - for (const submodule of skipSubmodules) { - if (rel.startsWith(submodule)) { - skip = true; - break; - } - } - if (skip) continue; - readmes.push(rel); - } + for await (const rel of glob.scan({ + cwd: repoRootAbs, + onlyFiles: true, + dot: false, + })) { + // Bun returns POSIX-style rel paths. + if (rel === "README.md") continue; // exclude root README + if (rel.startsWith(".git/") || rel.includes("/.git/")) continue; + if (rel.startsWith("node_modules/") || rel.includes("/node_modules/")) + continue; + let skip = false; + for (const submodule of skipSubmodules) { + if (rel.startsWith(submodule)) { + skip = true; + break; + } + } + if (skip) continue; + readmes.push(rel); + } - // Deterministic order. - readmes.sort((a, b) => a.localeCompare(b)); - return readmes; + // Deterministic order. + readmes.sort((a, b) => a.localeCompare(b)); + return readmes; } -async function writeImplDocCopy(params: { - srcAbs: string; - dstAbs: string; - pkgPath: string; - readmeRelToDocRoute: Map; - dirPathToDocRoute: Map; - repoUrl: string; +async function writeImplDocToMdx(params: { + srcAbs: string; + dstAbs: string; + pkgPath: string; + readmeRelToDocRoute: Map; + dirPathToDocRoute: Map; + repoUrl: string; }) { - const { - srcAbs, - dstAbs, - pkgPath, - readmeRelToDocRoute, - dirPathToDocRoute, - repoUrl, - } = params; - await mkdir(path.dirname(dstAbs), { recursive: true }); - await rm(dstAbs, { force: true }); + const { + srcAbs, + dstAbs, + pkgPath, + readmeRelToDocRoute, + dirPathToDocRoute, + repoUrl, + } = params; + await mkdir(path.dirname(dstAbs), { recursive: true }); - const original = await readFile(srcAbs, "utf8"); - const rewritten = rewriteImplMarkdown({ - md: original, - pkgPath, - readmeRelToDocRoute, - dirPathToDocRoute, - repoUrl, - }); - await writeFile(dstAbs, md2mdx(rewritten)); + const original = await readFile(srcAbs, "utf8"); + const current = await readFile(dstAbs, "utf-8"); + const rewritten = md2mdx( + rewriteImplMarkdown({ + md: original, + pkgPath, + readmeRelToDocRoute, + dirPathToDocRoute, + repoUrl, + }), + ); + + if (current === rewritten) { + return; + } + + await writeFile(dstAbs, rewritten, "utf-8"); + console.log(`[W] ${srcAbs} -> ${dstAbs}`); } async function syncImplDocs( - repoRootAbs: string, - wikiRootAbs: string, -): Promise { - const implDirAbs = path.join(wikiRootAbs, "content", "docs", "impl"); - await mkdir(implDirAbs, { recursive: true }); + repoRootAbs: string, + wikiRootAbs: string, +): Promise { + const implDirAbs = path.join(wikiRootAbs, "content", "docs", "impl"); + await mkdir(implDirAbs, { recursive: true }); - const readmes = await listRepoReadmes(repoRootAbs); - const docs: ImplDoc[] = []; - const expectedFileNames = new Set(); - expectedFileNames.add("index.mdx"); - expectedFileNames.add("meta.json"); + const readmes = await listRepoReadmes(repoRootAbs); + const expectedFileNames = new Set(); + expectedFileNames.add("index.mdx"); + expectedFileNames.add("meta.json"); - const repoUrl = normalizeRepoUrl( - Bun.env.REPO_URL ?? "https://github.com/yusing/godoxy", - ); + const repoUrl = normalizeRepoUrl( + Bun.env.REPO_URL ?? "https://github.com/yusing/godoxy", + ); - // Precompute mapping from repo-relative README path -> VitePress route. - // This lets us rewrite intra-repo README links when copying content. - const readmeRelToDocRoute = new Map(); + // Precompute mapping from repo-relative README path -> VitePress route. + // This lets us rewrite intra-repo README links when copying content. + const readmeRelToDocRoute = new Map(); - // Also precompute mapping from directory path -> VitePress route. - // This handles links like "[`common/`](common)" that point to directories with READMEs. - const dirPathToDocRoute = new Map(); + // Also precompute mapping from directory path -> VitePress route. + // This handles links like "[`common/`](common)" that point to directories with READMEs. + const dirPathToDocRoute = new Map(); - for (const readmeRel of readmes) { - const pkgPath = path.posix.dirname(readmeRel); - if (!pkgPath || pkgPath === ".") continue; + for (const readmeRel of readmes) { + const pkgPath = path.posix.dirname(readmeRel); + if (!pkgPath || pkgPath === ".") continue; - const docStem = sanitizeFileStemFromPkgPath(pkgPath); - if (!docStem) continue; - const route = `/impl/${docStem}`; - readmeRelToDocRoute.set(readmeRel, route); - dirPathToDocRoute.set(pkgPath, route); - } + const docStem = sanitizeFileStemFromPkgPath(pkgPath); + if (!docStem) continue; + const route = `/impl/${docStem}`; + readmeRelToDocRoute.set(readmeRel, route); + dirPathToDocRoute.set(pkgPath, route); + } - for (const readmeRel of readmes) { - const pkgPath = path.posix.dirname(readmeRel); - if (!pkgPath || pkgPath === ".") continue; + for (const readmeRel of readmes) { + const pkgPath = path.posix.dirname(readmeRel); + if (!pkgPath || pkgPath === ".") continue; - const docStem = sanitizeFileStemFromPkgPath(pkgPath); - if (!docStem) continue; - const docFileName = `${docStem}.mdx`; - const docRoute = `/impl/${docStem}`; + const docStem = sanitizeFileStemFromPkgPath(pkgPath); + if (!docStem) continue; + const docFileName = `${docStem}.mdx`; - const srcPathAbs = path.join(repoRootAbs, readmeRel); - const dstPathAbs = path.join(implDirAbs, docFileName); + const srcPathAbs = path.join(repoRootAbs, readmeRel); + const dstPathAbs = path.join(implDirAbs, docFileName); - await writeImplDocCopy({ - srcAbs: srcPathAbs, - dstAbs: dstPathAbs, - pkgPath, - readmeRelToDocRoute, - dirPathToDocRoute, - repoUrl, - }); + await writeImplDocToMdx({ + srcAbs: srcPathAbs, + dstAbs: dstPathAbs, + pkgPath, + readmeRelToDocRoute, + dirPathToDocRoute, + repoUrl, + }); - docs.push({ pkgPath, docFileName, docRoute, srcPathAbs, dstPathAbs }); - expectedFileNames.add(docFileName); - } + expectedFileNames.add(docFileName); + } - // Clean orphaned impl docs. - const existing = await readdir(implDirAbs, { withFileTypes: true }); - for (const ent of existing) { - if (!ent.isFile()) continue; - if (!ent.name.endsWith(".md")) continue; - if (expectedFileNames.has(ent.name)) continue; - await rm(path.join(implDirAbs, ent.name), { force: true }); - } - - // Deterministic for sidebar. - docs.sort((a, b) => a.pkgPath.localeCompare(b.pkgPath)); - return docs; + // Clean orphaned impl docs. + const existing = await readdir(implDirAbs, { withFileTypes: true }); + for (const ent of existing) { + if (!ent.isFile()) continue; + if (!ent.name.endsWith(".md")) continue; + if (expectedFileNames.has(ent.name)) continue; + await rm(path.join(implDirAbs, ent.name), { force: true }); + } } async function main() { - // This script lives in `scripts/update-wiki/`, so repo root is two levels up. - const repoRootAbs = path.resolve(import.meta.dir); + // This script lives in `scripts/update-wiki/`, so repo root is two levels up. + const repoRootAbs = path.resolve(import.meta.dir, "../.."); - // Required by task, but allow overriding via env for convenience. - const wikiRootAbs = Bun.env.DOCS_DIR - ? path.resolve(repoRootAbs, Bun.env.DOCS_DIR) - : undefined; + // Required by task, but allow overriding via env for convenience. + const wikiRootAbs = Bun.env.DOCS_DIR + ? path.resolve(repoRootAbs, Bun.env.DOCS_DIR) + : undefined; - if (!wikiRootAbs) { - throw new Error("DOCS_DIR is not set"); - } + if (!wikiRootAbs) { + throw new Error("DOCS_DIR is not set"); + } - await syncImplDocs(repoRootAbs, wikiRootAbs); + await syncImplDocs(repoRootAbs, wikiRootAbs); } await main();