refactor(docs): migrate wiki generation to MDX format

- Convert markdown output to  fumadocs MDX
- Add api-md2mdx.ts for markdown to MDX transformation
- Remove sidebar auto-update functionality
- Change output directory from src/impl to content/docs/impl
- Update DOCS_DIR path in Makefile to local wiki directory
- Copy swagger.json directly instead of generating markdown
- Add argparse dependency for CLI argument parsing
This commit is contained in:
yusing
2026-02-18 14:05:40 +08:00
parent 20695c52e8
commit c2d8cca3b4
6 changed files with 393 additions and 325 deletions

View File

@@ -7,7 +7,7 @@ export GOOS = linux
REPO_URL ?= https://github.com/yusing/godoxy REPO_URL ?= https://github.com/yusing/godoxy
WEBUI_DIR ?= ../godoxy-webui WEBUI_DIR ?= ../godoxy-webui
DOCS_DIR ?= ${WEBUI_DIR}/wiki DOCS_DIR ?= wiki
ifneq ($(BRANCH), compat) ifneq ($(BRANCH), compat)
GO_TAGS = sonic GO_TAGS = sonic
@@ -178,10 +178,7 @@ gen-swagger:
python3 scripts/fix-swagger-json.py python3 scripts/fix-swagger-json.py
# we don't need this # we don't need this
rm internal/api/v1/docs/docs.go rm internal/api/v1/docs/docs.go
cp internal/api/v1/docs/swagger.json ${DOCS_DIR}/public/api.json
gen-swagger-markdown: gen-swagger
# brew tap go-swagger/go-swagger && brew install go-swagger
swagger generate markdown -f internal/api/v1/docs/swagger.yaml --skip-validation --output ${DOCS_DIR}/src/API.md
gen-api-types: gen-swagger gen-api-types: gen-swagger
# --disable-throw-on-error # --disable-throw-on-error

Submodule goutils updated: 860d05e804...01cd6d408c

View File

@@ -0,0 +1,114 @@
export function md2mdx(md: string) {
const indexFirstH2 = md.indexOf("## ");
if (indexFirstH2 === -1) {
console.error("## section not found in the file");
process.exit(1);
}
const h1 = md.slice(0, indexFirstH2);
const h1Lines = h1.split("\n");
const keptH1Lines: string[] = [];
const callouts: string[] = [];
for (let i = 0; i < h1Lines.length; i++) {
const line = h1Lines[i] ?? "";
const calloutStart = line.match(/^>\s*\[!([a-z0-9_-]+)\]\s*$/i);
if (calloutStart) {
const rawCalloutType = (calloutStart[1] ?? "note").toLowerCase();
const calloutType =
rawCalloutType === "note"
? "info"
: rawCalloutType === "warning"
? "warn"
: rawCalloutType;
const contentLines: string[] = [];
i++;
for (; i < h1Lines.length; i++) {
const blockLine = h1Lines[i] ?? "";
if (!blockLine.startsWith(">")) {
i--;
break;
}
contentLines.push(blockLine.replace(/^>\s?/, ""));
}
while (contentLines[0] === "") {
contentLines.shift();
}
while (contentLines[contentLines.length - 1] === "") {
contentLines.pop();
}
if (contentLines.length > 0) {
callouts.push(
`<Callout type="${calloutType}">\n${contentLines.join("\n")}\n</Callout>`,
);
}
continue;
}
keptH1Lines.push(line);
}
const h1WithoutCallout = keptH1Lines.join("\n");
const titleMatchResult = h1WithoutCallout.match(
new RegExp(/^\s*#\s+([^\n]+)/, "im"),
);
const title = titleMatchResult?.[1]?.trim() ?? "";
let description = h1WithoutCallout
.replace(new RegExp(/^\s*#\s+[^\n]+\n?/, "im"), "")
.replaceAll(new RegExp(/^\s*>.+$/, "gm"), "")
.trim();
// remove trailing full stop
if (description.endsWith(".")) {
description = description.slice(0, -1);
}
let header = `---\ntitle: ${title}`;
if (description) {
header += `\ndescription: ${description}`;
}
header += "\n---";
md = md.slice(indexFirstH2);
const calloutsBlock = callouts.join("\n\n");
if (calloutsBlock) {
md = `${header}\n\n${calloutsBlock}\n\n${md}`;
} else {
md = `${header}\n\n${md}`;
}
md = md.replaceAll("</br>", "<br/>");
md = md.replaceAll("<0", "\\<0");
return md;
}
async function main() {
const Parser = await import("argparse").then((m) => m.ArgumentParser);
const parser = new Parser({
description: "Convert API markdown to VitePress MDX",
});
parser.add_argument("-i", "--input", {
help: "Input markdown file",
required: true,
});
parser.add_argument("-o", "--output", {
help: "Output VitePress MDX file",
required: true,
});
const args = parser.parse_args();
const inMdFile = args.input;
const outMdxFile = args.output;
const md = await Bun.file(inMdFile).text();
const mdx = md2mdx(md);
await Bun.write(outMdxFile, mdx);
}
if (import.meta.main) {
await main();
}

View File

@@ -4,20 +4,28 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "update-wiki", "name": "update-wiki",
"dependencies": {
"argparse": "^2.0.1",
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/argparse": "^2.0.17",
"@types/bun": "^1.3.9",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5", "typescript": "^5.9.3",
}, },
}, },
}, },
"packages": { "packages": {
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/argparse": ["@types/argparse@2.0.17", "", {}, "sha512-fueJssTf+4dW4HODshEGkIZbkLKHzgu1FvCI4cTc/MKum/534Euo3SrN+ilq8xgyHnOjtmg33/hee8iXLRg1XA=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],

View File

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

View File

@@ -2,9 +2,13 @@
"name": "update-wiki", "name": "update-wiki",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/argparse": "^2.0.17",
"@types/bun": "^1.3.9"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5.9.3"
},
"dependencies": {
"argparse": "^2.0.1"
} }
} }