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: 
+ out = out.replace(
+ /!\[([^\]]*)\]\(([^)\s]+)(\s+["'][^"']*["'])?\)/g,
+ (m, text, href, title) => {
+ const newHref = resolveHref(href, true);
+ return ``;
+ }
+ );
+
+ // 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 = ``;
+ const out = rewriteWikiLinks(md, {
+ srcFile: "docs/architecture/ARCHITECTURE.md",
+ });
+ assert.equal(
+ out,
+ ``
+ );
+});
+
+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 });