diff --git a/Makefile b/Makefile index b5ca0f3e..5c0147ac 100755 --- a/Makefile +++ b/Makefile @@ -171,4 +171,8 @@ gen-api-types: gen-swagger # --disable-throw-on-error bunx --bun swagger-typescript-api generate --sort-types --generate-union-enums --axios --add-readonly --route-types \ --responses -o ${WEBUI_DIR}/lib -n api.ts -p internal/api/v1/docs/swagger.json - bunx --bun prettier --config ${WEBUI_DIR}/.prettierrc --write ${WEBUI_DIR}/lib/api.ts \ No newline at end of file + bunx --bun prettier --config ${WEBUI_DIR}/.prettierrc --write ${WEBUI_DIR}/lib/api.ts + +.PHONY: update-wiki +update-wiki: + DOCS_DIR=${DOCS_DIR} bun --bun scripts/update-wiki/main.ts diff --git a/scripts/update-wiki/.gitignore b/scripts/update-wiki/.gitignore new file mode 100644 index 00000000..b512c09d --- /dev/null +++ b/scripts/update-wiki/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/scripts/update-wiki/bun.lock b/scripts/update-wiki/bun.lock new file mode 100644 index 00000000..f618b17a --- /dev/null +++ b/scripts/update-wiki/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "update-wiki", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@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=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/scripts/update-wiki/main.ts b/scripts/update-wiki/main.ts new file mode 100644 index 00000000..09918414 --- /dev/null +++ b/scripts/update-wiki/main.ts @@ -0,0 +1,175 @@ +import { Glob } from "bun"; +import { linkSync } from "fs"; +import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises"; +import path from "path"; + +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; + /** Absolute source README path */ + srcPathAbs: string; + /** Absolute destination doc path */ + dstPathAbs: string; +}; + +const START_MARKER = "// GENERATED-IMPL-SIDEBAR-START"; +const END_MARKER = "// GENERATED-IMPL-SIDEBAR-END"; + +function escapeRegex(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function escapeSingleQuotedTs(s: string) { + return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +} + +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, ""); +} + +async function listRepoReadmes(repoRootAbs: string): Promise { + 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; + if (rel.startsWith("internal/go-oidc/")) continue; + if (rel.startsWith("internal/gopsutil/")) continue; + readmes.push(rel); + } + + // Deterministic order. + readmes.sort((a, b) => a.localeCompare(b)); + return readmes; +} + +async function ensureHardLink(srcAbs: string, dstAbs: string) { + await mkdir(path.dirname(dstAbs), { recursive: true }); + await rm(dstAbs, { force: true }); + // Prefer sync for better error surfaces in Bun on some platforms. + linkSync(srcAbs, dstAbs); +} + +async function syncImplDocs( + repoRootAbs: string, + wikiRootAbs: string +): Promise { + const implDirAbs = path.join(wikiRootAbs, "src", "impl"); + await mkdir(implDirAbs, { recursive: true }); + + const readmes = await listRepoReadmes(repoRootAbs); + const docs: ImplDoc[] = []; + const expectedFileNames = new Set(); + + 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}.md`; + + const srcPathAbs = path.join(repoRootAbs, readmeRel); + const dstPathAbs = path.join(implDirAbs, docFileName); + + await ensureHardLink(srcPathAbs, dstPathAbs); + + docs.push({ pkgPath, docFileName, srcPathAbs, dstPathAbs }); + 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; +} + +function renderSidebarItems(docs: ImplDoc[], indent: string) { + // link: '/impl/.md' because VitePress `srcDir = "src"`. + if (docs.length === 0) return ""; + return ( + docs + .map((d) => { + const text = escapeSingleQuotedTs(d.pkgPath); + const link = escapeSingleQuotedTs(`/impl/${d.docFileName}`); + 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() { + // 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) + : path.resolve(repoRootAbs, "..", "godoxy-webui", "wiki"); + + const docs = await syncImplDocs(repoRootAbs, wikiRootAbs); + await updateVitepressSidebar(wikiRootAbs, docs); +} + +await main(); diff --git a/scripts/update-wiki/package.json b/scripts/update-wiki/package.json new file mode 100644 index 00000000..b5aec0d0 --- /dev/null +++ b/scripts/update-wiki/package.json @@ -0,0 +1,10 @@ +{ + "name": "update-wiki", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/scripts/update-wiki/tsconfig.json b/scripts/update-wiki/tsconfig.json new file mode 100644 index 00000000..bfa0fead --- /dev/null +++ b/scripts/update-wiki/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}