mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-24 09:48:49 +02:00
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:
7
Makefile
7
Makefile
@@ -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
|
||||||
|
|||||||
2
goutils
2
goutils
Submodule goutils updated: 860d05e804...01cd6d408c
114
scripts/update-wiki/api-md2mdx.ts
Normal file
114
scripts/update-wiki/api-md2mdx.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -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") / 
|
// Inline markdown links/images: [text](url "title") / 
|
||||||
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();
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user