From 743eb03b27f34f4024b69d4d6217476679196561 Mon Sep 17 00:00:00 2001 From: yusing Date: Tue, 24 Feb 2026 15:10:03 +0800 Subject: [PATCH] fix(scripts): correct repo root path in update-wiki The repoRootAbs was resolving to the script directory instead of the repository root. Fixed by resolving two levels up from import.meta.dir. Also optimized writeImplDocToMdx to skip writes when content is unchanged and removed unused return value from syncImplDocs. --- scripts/update-wiki/main.ts | 519 ++++++++++++++++++------------------ 1 file changed, 260 insertions(+), 259 deletions(-) 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();