Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 126 additions & 7 deletions scripts/docs/sync-wiki.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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: <a ... href="..." ...>
out = out.replace(
/<a\b([^>]*\bhref=["'])([^"']+)(["'][^>]*)>/gi,
(m, before, href, after) => {
const newHref = resolveHref(href, false);
return `<a${before}${newHref}${after}>`;
}
);

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";
}

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 12 additions & 3 deletions src/app/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -63,7 +64,9 @@ async function tryI18nFallback(slug: string[], locale: string): Promise<string |
import("marked"),
import("@/lib/docsSanitizer"),
]);
const html = marked.parse(body) as string;
const docRelPath = `${slug.join("/")}.md`;
const normalizedBody = normalizeDocsMarkdownLinks(body, docRelPath);
const html = marked.parse(normalizedBody) as string;
return sanitizeDocsHtml(html);
}

Expand Down Expand Up @@ -93,12 +96,18 @@ export default async function Page(props: { params: Promise<{ slug: string[] }>
);
}

// 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<typeof defaultMdxComponents.a>) => {
const resolved = linkProps.href ? resolveDocHref(linkProps.href, docPath) : linkProps.href;
return <defaultMdxComponents.a {...linkProps} href={resolved} />;
};

return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
<MDX components={{ ...defaultMdxComponents, a: DocsLink }} />
</DocsBody>
</DocsPage>
);
Expand Down
108 changes: 108 additions & 0 deletions src/lib/docsLinkResolver.ts
Original file line number Diff line number Diff line change
@@ -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/<section>/<slug>[#anchor]`
* - Root-level doc paths (`/docs/...` or `docs/...`) -> `/docs/<section>/<slug>[#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: <a ... href="..." ...>
out = out.replace(
/<a\b([^>]*\bhref=["'])([^"']+)(["'][^>]*)>/gi,
(_match, before, href, after) => {
const newHref = resolveDocHref(href, currentDocRelPath);
return `<a${before}${newHref}${after}>`;
}
);

return out;
}
Loading
Loading