diff --git a/scripts/docs/sync-wiki.mjs b/scripts/docs/sync-wiki.mjs index ec70f685cf..737dd59609 100644 --- a/scripts/docs/sync-wiki.mjs +++ b/scripts/docs/sync-wiki.mjs @@ -40,6 +40,8 @@ const ROOT = path.resolve(__dirname, "..", ".."); // U+2010 HYPHEN separates the locale prefix in localized wiki page names. const LOCALE_SEP = "โ€"; export const WIKI_BANNER = "> ๐ŸŒ [View in other languages](Languages)\n\n\n"; +export const GITHUB_REPO_URL = "https://github.com/diegosouzapw/OmniRoute"; +export const GITHUB_RAW_URL = "https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main"; // Docs that must never become public wiki pages (internal reports/plans/index). export const NEW_PAGE_EXCLUDE = new Set([ @@ -103,9 +105,104 @@ export function toWikiName(basename) { .join("-"); } +/** + * Rewrites relative doc and code links in markdown for GitHub Wiki flat structure. + * - Relative doc links within `docs/` -> `Wiki-Page-Name[#anchor]` + * - Links to repository source code or root files outside `docs/` -> GitHub blob URL + * - Non-markdown doc assets (e.g. SVG diagrams) -> Raw GitHub usercontent URL + * - Pure anchor links (`#section`) and external links (`https://...`) -> untouched + */ +export function rewriteWikiLinks( + content, + { + srcFile = null, + wikiKeyMap = null, + locale = null, + repoUrl = GITHUB_REPO_URL, + rawUrl = GITHUB_RAW_URL, + } = {} +) { + const docDir = srcFile ? path.dirname(path.resolve(ROOT, srcFile)) : path.join(ROOT, "docs"); + + function resolveHref(href, isImage = false) { + if (!href || /^(?:https?:|mailto:|#)/i.test(href)) return href; + + const [rawPath, anchor] = href.includes("#") + ? [href.slice(0, href.indexOf("#")), href.slice(href.indexOf("#") + 1)] + : [href, null]; + if (!rawPath) return href; + + const resolvedAbs = path.resolve(docDir, rawPath); + const repoRel = path.relative(ROOT, resolvedAbs).replace(/\\/g, "/"); + const anchorSuffix = anchor ? `#${anchor}` : ""; + + // If inside docs/ and ends with .md + if ((repoRel.startsWith("docs/") || repoRel.startsWith("docs\\")) && repoRel.endsWith(".md")) { + const base = path.basename(repoRel, ".md"); + if (NEW_PAGE_EXCLUDE.has(base)) { + return `${repoUrl}/blob/main/${repoRel}${anchorSuffix}`; + } + const key = normKey(base); + const wikiName = wikiKeyMap?.get(key) || toWikiName(base); + const prefix = locale ? `${locale}${LOCALE_SEP}` : ""; + return `${prefix}${wikiName}${anchorSuffix}`; + } + + if (isImage || /\.(?:png|jpe?g|gif|svg|webp)$/i.test(repoRel)) { + return `${rawUrl}/${repoRel}`; + } + + // Repo code or root markdown file (README, CHANGELOG, etc.) + return `${repoUrl}/blob/main/${repoRel}${anchorSuffix}`; + } + + let out = content; + + // 1. Inline links: [text](url "title"?) + out = out.replace( + /(^|[^!])\[([^\]]*)\]\(([^)\s]+)(\s+["'][^"']*["'])?\)/g, + (m, lead, text, href, title) => { + const newHref = resolveHref(href, false); + return `${lead}[${text}](${newHref}${title || ""})`; + } + ); + + // 2. Images: ![alt](url "title"?) + out = out.replace( + /!\[([^\]]*)\]\(([^)\s]+)(\s+["'][^"']*["'])?\)/g, + (m, text, href, title) => { + const newHref = resolveHref(href, true); + return `![${text}](${newHref}${title || ""})`; + } + ); + + // 3. Reference links: ^[label]: href "title"? + out = out.replace( + /^\[([^\]]+)\]:\s*([^\s]+)(\s+["'][^"']*["'])?$/gm, + (m, label, href, title) => { + const newHref = resolveHref(href, false); + return `[${label}]: ${newHref}${title || ""}`; + } + ); + + // 4. HTML anchors: + out = out.replace( + /]*\bhref=["'])([^"']+)(["'][^>]*)>/gi, + (m, before, href, after) => { + const newHref = resolveHref(href, false); + return ``; + } + ); + + return out; +} + /** Strip YAML frontmatter and prepend the wiki language banner. Pure; exported for tests. */ -export function toWikiContent(docMarkdown) { - const body = docMarkdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").replace(/^\s+/, ""); +export function toWikiContent(docMarkdown, options = {}) { + let body = docMarkdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").replace(/^\s+/, ""); + if (options.srcFile || options.wikiKeyMap || options.locale) { + body = rewriteWikiLinks(body, options); + } return WIKI_BANNER + body.replace(/\s*$/, "") + "\n"; } @@ -239,6 +336,20 @@ function main() { const enWikiKeys = new Set(); const localeIndexes = new Map(); + // Pre-build normKey -> wikiPageName map for English docs + const wikiKeyMap = new Map(); + for (const page of wikiPages) { + const { locale, name } = parseWikiPage(page); + if (!locale && page !== "Home") { + wikiKeyMap.set(normKey(name), name); + } + } + for (const [key, { base }] of enDocs) { + if (!wikiKeyMap.has(key) && !NEW_PAGE_EXCLUDE.has(base)) { + wikiKeyMap.set(key, toWikiName(base)); + } + } + const plan = { update: [], add: [], untouched: [], countsChanged: false }; // 1. Update existing wiki pages from their docs source. @@ -258,16 +369,20 @@ function main() { plan.untouched.push(page); continue; } - const next = toWikiContent(fs.readFileSync(srcFile, "utf8")); + const next = toWikiContent(fs.readFileSync(srcFile, "utf8"), { + srcFile, + wikiKeyMap, + locale, + }); const cur = fs.readFileSync(path.join(wikiDir, `${page}.md`), "utf8"); - if (next !== cur) plan.update.push({ page, srcFile }); + if (next !== cur) plan.update.push({ page, srcFile, locale }); } // 2. Add curated new English pages (unmatched docs, minus the exclude list). for (const [key, { file, base }] of enDocs) { if (enWikiKeys.has(key)) continue; if (NEW_PAGE_EXCLUDE.has(base)) continue; - plan.add.push({ page: toWikiName(base), srcFile: file, base }); + plan.add.push({ page: toWikiName(base), srcFile: file, base, locale: null }); } // 3. Home cover counts. @@ -313,10 +428,14 @@ function main() { } // ---- write ---- - for (const { page, srcFile } of [...updates, ...plan.add]) { + for (const { page, srcFile, locale } of [...updates, ...plan.add]) { fs.writeFileSync( path.join(wikiDir, `${page}.md`), - toWikiContent(fs.readFileSync(srcFile, "utf8")) + toWikiContent(fs.readFileSync(srcFile, "utf8"), { + srcFile, + wikiKeyMap, + locale: locale || parseWikiPage(page).locale, + }) ); } if (plan.countsChanged && homeAfter != null) fs.writeFileSync(homePath, homeAfter); diff --git a/src/app/docs/[...slug]/page.tsx b/src/app/docs/[...slug]/page.tsx index c1a023aa93..29207f17dc 100644 --- a/src/app/docs/[...slug]/page.tsx +++ b/src/app/docs/[...slug]/page.tsx @@ -5,6 +5,7 @@ import { DEFAULT_LOCALE, LOCALE_COOKIE } from "@/i18n/config"; import fs from "node:fs"; import path from "node:path"; import { resolveSafeI18nSectionDir } from "@/lib/docsI18nPath"; +import { resolveDocHref, normalizeDocsMarkdownLinks } from "@/lib/docsLinkResolver"; import { getTranslations } from "next-intl/server"; // โ”€โ”€ Locale detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -63,7 +64,9 @@ async function tryI18nFallback(slug: string[], locale: string): Promise ); } - // Default: English MDX rendered natively by Fumadocs + // Default: English MDX rendered natively by Fumadocs with resolved links const MDX = page.data.body; + const docPath = page.file?.path || `${params.slug.join("/")}.md`; + const DocsLink = (linkProps: React.ComponentProps) => { + const resolved = linkProps.href ? resolveDocHref(linkProps.href, docPath) : linkProps.href; + return ; + }; + return ( - + ); diff --git a/src/lib/docsLinkResolver.ts b/src/lib/docsLinkResolver.ts new file mode 100644 index 0000000000..fe2c8f3e16 --- /dev/null +++ b/src/lib/docsLinkResolver.ts @@ -0,0 +1,108 @@ +import path from "node:path"; + +export const GITHUB_REPO_BLOB_URL = "https://github.com/diegosouzapw/OmniRoute/blob/main"; + +/** + * Resolves a doc link (e.g. `../routing/AUTO-COMBO.md#14-factors`, `./RESILIENCE_GUIDE.md`, + * or `../../src/lib/db/core.ts`) into a proper Next.js Fumadocs route path or GitHub blob URL. + * + * - Relative doc links within `docs/` -> `/docs/
/[#anchor]` + * - Root-level doc paths (`/docs/...` or `docs/...`) -> `/docs/
/[#anchor]` + * - Links to repository source code or root files outside `docs/` -> GitHub blob URL + * - Pure anchor links (`#section`) and external links (`https://...`) -> untouched + */ +export function resolveDocHref(href: string, currentDocRelPath: string = ""): string { + if (!href || /^(?:https?:|mailto:|#)/i.test(href)) { + return href; + } + + const [rawPath, anchor] = href.includes("#") + ? [href.slice(0, href.indexOf("#")), href.slice(href.indexOf("#") + 1)] + : [href, ""]; + const anchorSuffix = anchor ? `#${anchor}` : ""; + + if (!rawPath) { + return href; + } + + // Absolute /docs/... or docs/... links + if (/^\/?docs\//i.test(rawPath)) { + const cleaned = rawPath.replace(/^\/?docs\//i, ""); + if (cleaned.toLowerCase().endsWith(".md")) { + return `/docs/${cleaned.slice(0, -3)}${anchorSuffix}`; + } + return `/docs/${cleaned}${anchorSuffix}`; + } + + // Relative links starting with ./ or ../ + if (rawPath.startsWith("./") || rawPath.startsWith("../")) { + const currentDir = currentDocRelPath ? path.dirname(currentDocRelPath) : ""; + const resolvedInDocs = path.normalize(path.join(currentDir, rawPath)).replace(/\\/g, "/"); + + // If the path escapes the docs/ tree into repo root (e.g. ../../src/... or ../../package.json) + if (resolvedInDocs.startsWith("../") || resolvedInDocs === "..") { + const repoRel = path.normalize(path.join("docs", currentDir, rawPath)).replace(/\\/g, "/"); + return `${GITHUB_REPO_BLOB_URL}/${repoRel}${anchorSuffix}`; + } + + // Inside docs/ ending with .md -> strip .md to form Fumadocs route slug + if (resolvedInDocs.toLowerCase().endsWith(".md")) { + return `/docs/${resolvedInDocs.slice(0, -3)}${anchorSuffix}`; + } + + // Static asset inside docs/ (e.g. diagrams/exported/foo.svg) + if (/\.(?:png|jpe?g|gif|svg|webp|json|yaml|yml|ts|js|mjs)$/i.test(resolvedInDocs)) { + return `${GITHUB_REPO_BLOB_URL}/docs/${resolvedInDocs}${anchorSuffix}`; + } + + return `/docs/${resolvedInDocs}${anchorSuffix}`; + } + + // Relative doc path without leading dots, e.g. "routing/AUTO-COMBO.md" + if (rawPath.toLowerCase().endsWith(".md")) { + return `/docs/${rawPath.slice(0, -3)}${anchorSuffix}`; + } + + return href; +} + +/** + * Normalizes all relative markdown links in a raw markdown string for HTML/i18n rendering. + */ +export function normalizeDocsMarkdownLinks( + markdown: string, + currentDocRelPath: string = "" +): string { + if (!markdown) return markdown; + + let out = markdown; + + // 1. Inline links: [text](url "title"?) + out = out.replace( + /(^|[^!])\[([^\]]*)\]\(([^)\s]+)(\s+["'][^"']*["'])?\)/g, + (_match, lead, text, href, title) => { + const newHref = resolveDocHref(href, currentDocRelPath); + return `${lead}[${text}](${newHref}${title || ""})`; + } + ); + + // 2. Reference links: ^[label]: href "title"? + out = out.replace( + /^\[([^\]]+)\]:\s*([^\s]+)(\s+["'][^"']*["'])?$/gm, + (_match, label, href, title) => { + const newHref = resolveDocHref(href, currentDocRelPath); + return `[${label}]: ${newHref}${title || ""}`; + } + ); + + // 3. HTML anchors: + out = out.replace( + /]*\bhref=["'])([^"']+)(["'][^>]*)>/gi, + (_match, before, href, after) => { + const newHref = resolveDocHref(href, currentDocRelPath); + return ``; + } + ); + + return out; +} diff --git a/tests/unit/docs-link-resolver.test.ts b/tests/unit/docs-link-resolver.test.ts new file mode 100644 index 0000000000..9217f311a2 --- /dev/null +++ b/tests/unit/docs-link-resolver.test.ts @@ -0,0 +1,107 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + resolveDocHref, + normalizeDocsMarkdownLinks, + GITHUB_REPO_BLOB_URL, +} from "../../src/lib/docsLinkResolver.js"; + +test("resolveDocHref: rewrites relative doc-to-doc links to extensionless Fumadocs paths", () => { + assert.equal( + resolveDocHref("../routing/AUTO-COMBO.md", "architecture/ARCHITECTURE.md"), + "/docs/routing/AUTO-COMBO" + ); + assert.equal( + resolveDocHref("./RESILIENCE_GUIDE.md", "architecture/ARCHITECTURE.md"), + "/docs/architecture/RESILIENCE_GUIDE" + ); + assert.equal( + resolveDocHref("getting-started/QUICK-START.md", "architecture/ARCHITECTURE.md"), + "/docs/getting-started/QUICK-START" + ); +}); + +test("resolveDocHref: preserves hash fragments in rewritten doc paths", () => { + assert.equal( + resolveDocHref("../routing/AUTO-COMBO.md#14-factors", "architecture/ARCHITECTURE.md"), + "/docs/routing/AUTO-COMBO#14-factors" + ); + assert.equal( + resolveDocHref("./RESILIENCE_GUIDE.md#circuit-breaker", "architecture/ARCHITECTURE.md"), + "/docs/architecture/RESILIENCE_GUIDE#circuit-breaker" + ); +}); + +test("resolveDocHref: normalizes root-level /docs/... and docs/... links", () => { + assert.equal( + resolveDocHref("/docs/frameworks/MCP-SERVER.md", "architecture/ARCHITECTURE.md"), + "/docs/frameworks/MCP-SERVER" + ); + assert.equal( + resolveDocHref("docs/frameworks/MCP-SERVER.md#canonical-tools", "architecture/ARCHITECTURE.md"), + "/docs/frameworks/MCP-SERVER#canonical-tools" + ); +}); + +test("resolveDocHref: rewrites links escaping docs/ to GitHub repo blob URLs", () => { + assert.equal( + resolveDocHref("../../src/lib/db/core.ts", "architecture/ARCHITECTURE.md"), + `${GITHUB_REPO_BLOB_URL}/src/lib/db/core.ts` + ); + assert.equal( + resolveDocHref("../../package.json", "architecture/ARCHITECTURE.md"), + `${GITHUB_REPO_BLOB_URL}/package.json` + ); + assert.equal( + resolveDocHref("../../README.md", "architecture/ARCHITECTURE.md"), + `${GITHUB_REPO_BLOB_URL}/README.md` + ); +}); + +test("resolveDocHref: leaves external URLs and pure anchor links untouched", () => { + assert.equal( + resolveDocHref("https://github.com/diegosouzapw/OmniRoute", "architecture/ARCHITECTURE.md"), + "https://github.com/diegosouzapw/OmniRoute" + ); + assert.equal( + resolveDocHref("http://localhost:20128/v1", "architecture/ARCHITECTURE.md"), + "http://localhost:20128/v1" + ); + assert.equal( + resolveDocHref("mailto:support@example.com", "architecture/ARCHITECTURE.md"), + "mailto:support@example.com" + ); + assert.equal( + resolveDocHref("#pipeline", "architecture/ARCHITECTURE.md"), + "#pipeline" + ); + assert.equal( + resolveDocHref("", "architecture/ARCHITECTURE.md"), + "" + ); +}); + +test("normalizeDocsMarkdownLinks: rewrites inline, reference, and HTML links in markdown", () => { + const md = ` +# Sample Docs + +See the [Auto Combo](../routing/AUTO-COMBO.md) guide or [Resilience Docs](./RESILIENCE_GUIDE.md#layers). +Check [DB Implementation](../../src/lib/db/core.ts) for details. +Reference: [External][1] and [Internal Reference][2]. +Pure anchor: [Jump to top](#top). +HTML link: Scoring. + +[1]: https://example.com "Example" +[2]: ../frameworks/MCP-SERVER.md "MCP" +`; + + const out = normalizeDocsMarkdownLinks(md, "architecture/ARCHITECTURE.md"); + + assert.ok(out.includes("[Auto Combo](/docs/routing/AUTO-COMBO)")); + assert.ok(out.includes("[Resilience Docs](/docs/architecture/RESILIENCE_GUIDE#layers)")); + assert.ok(out.includes(`[DB Implementation](${GITHUB_REPO_BLOB_URL}/src/lib/db/core.ts)`)); + assert.ok(out.includes("[Jump to top](#top)")); + assert.ok(out.includes('Scoring')); + assert.ok(out.includes('[2]: /docs/frameworks/MCP-SERVER "MCP"')); + assert.ok(out.includes('[1]: https://example.com "Example"')); +}); diff --git a/tests/unit/sync-wiki.test.ts b/tests/unit/sync-wiki.test.ts index 33e35cfe8c..96b9a2f06b 100644 --- a/tests/unit/sync-wiki.test.ts +++ b/tests/unit/sync-wiki.test.ts @@ -6,10 +6,13 @@ import { normKey, toWikiName, toWikiContent, + rewriteWikiLinks, syncHomeCounts, parseWikiPage, WIKI_BANNER, NEW_PAGE_EXCLUDE, + GITHUB_REPO_URL, + GITHUB_RAW_URL, } from "../../scripts/docs/sync-wiki.mjs"; test("normKey: case/separator-insensitive fuzzy key (matches docs basename to curated wiki name)", () => { @@ -42,6 +45,83 @@ test("toWikiContent: a document without frontmatter is kept intact (plus banner) assert.equal(out, WIKI_BANNER + "# No Frontmatter\n\nHello.\n"); }); +test("rewriteWikiLinks: converts relative doc links to curated wiki page names", () => { + const wikiKeyMap = new Map([ + ["autocombo", "Auto-Combo"], + ["resilienceguide", "Resilience-Guide"], + ["mcpserver", "MCP-Server"], + ]); + + const md = ` +# Architecture + +See [Auto-Combo](../routing/AUTO-COMBO.md) and [Resilience](./RESILIENCE_GUIDE.md). +With anchor: [14 Factors](../routing/AUTO-COMBO.md#14-factors). +Ref style: [MCP][mcp-ref] +HTML: Strategies + +[mcp-ref]: ../frameworks/MCP-SERVER.md "MCP Tools" +`; + + const out = rewriteWikiLinks(md, { + srcFile: "docs/architecture/ARCHITECTURE.md", + wikiKeyMap, + }); + + assert.ok(out.includes("[Auto-Combo](Auto-Combo)")); + assert.ok(out.includes("[Resilience](Resilience-Guide)")); + assert.ok(out.includes("[14 Factors](Auto-Combo#14-factors)")); + assert.ok(out.includes('[mcp-ref]: MCP-Server "MCP Tools"')); + assert.ok(out.includes('Strategies')); +}); + +test("rewriteWikiLinks: converts repo code and root markdown links to GitHub blob URLs", () => { + const md = ` +Refer to [DB Core](../../src/lib/db/core.ts) and [README](../README.md). +Check [Package config](../../package.json) and [Auth Guard](../../src/server/authz/routeGuard.ts). +`; + + const out = rewriteWikiLinks(md, { + srcFile: "docs/architecture/ARCHITECTURE.md", + }); + + assert.ok(out.includes(`[DB Core](${GITHUB_REPO_URL}/blob/main/src/lib/db/core.ts)`)); + assert.ok(out.includes(`[README](${GITHUB_REPO_URL}/blob/main/docs/README.md)`)); + assert.ok(out.includes(`[Package config](${GITHUB_REPO_URL}/blob/main/package.json)`)); + assert.ok(out.includes(`[Auth Guard](${GITHUB_REPO_URL}/blob/main/src/server/authz/routeGuard.ts)`)); +}); + +test("rewriteWikiLinks: rewrites image links to GitHub raw content URLs", () => { + const md = `![Architecture Overview](../diagrams/exported/resilience-3layers.svg "Diagram")`; + const out = rewriteWikiLinks(md, { + srcFile: "docs/architecture/ARCHITECTURE.md", + }); + assert.equal( + out, + `![Architecture Overview](${GITHUB_RAW_URL}/docs/diagrams/exported/resilience-3layers.svg "Diagram")` + ); +}); + +test("rewriteWikiLinks: preserves pure anchor links and external URLs", () => { + const md = `[Top](#top) and [Website](https://example.com) and [Email](mailto:test@example.com)`; + const out = rewriteWikiLinks(md, { + srcFile: "docs/architecture/ARCHITECTURE.md", + }); + assert.equal(out, md); +}); + +test("rewriteWikiLinks: prepends locale prefix for localized wiki pages", () => { + const wikiKeyMap = new Map([["autocombo", "Auto-Combo"]]); + const md = `[Auto-Combo](../routing/AUTO-COMBO.md#scoring)`; + const out = rewriteWikiLinks(md, { + srcFile: "docs/i18n/pt-BR/routing/AUTO-COMBO.md", + wikiKeyMap, + locale: "pt-BR", + }); + // U+2010 HYPHEN + assert.equal(out, "[Auto-Combo](pt-BR\u2010Auto-Combo#scoring)"); +}); + test("syncHomeCounts: rewrites the cover-page provider/strategy counts", () => { const home = "Connect every AI tool to 177 providers.\n**177 AI Providers** ยท **14 Routing Strategies**\n"; const out = syncHomeCounts(home, { providers: 226, strategies: 15, mcpTools: null, locales: 42 });