diff --git a/app/components/Markdown.tsx b/app/components/Markdown.tsx
index 84d1c72..3849e3a 100644
--- a/app/components/Markdown.tsx
+++ b/app/components/Markdown.tsx
@@ -2,12 +2,19 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
+import Link from "next/link";
import { useMemo } from 'react';
import type { ReactElement } from 'react';
import Code from './Code';
import { kebabCase } from 'change-case';
+import { resolveMarkdownLink } from '../lib/markdownLinks';
-export default function Markdown({ content }: { content: string }) {
+interface MarkdownProps {
+ content: string;
+ sourcePath: string;
+}
+
+export default function Markdown({ content, sourcePath }: MarkdownProps) {
const processedContent = useMemo(() => {
// Split content by H2 headings to group sections
const sections = content.split(/^## /gm);
@@ -22,7 +29,7 @@ export default function Markdown({ content }: { content: string }) {
{section}
@@ -42,7 +49,7 @@ export default function Markdown({ content }: { content: string }) {
{sectionContent}
@@ -52,12 +59,12 @@ export default function Markdown({ content }: { content: string }) {
});
return elements;
- }, [content]);
+ }, [content, sourcePath]);
return
{processedContent}
;
}
-function getMarkdownComponents() {
+function getMarkdownComponents(sourcePath: string) {
return {
// H1 headings (main page title from Markdown content)
h1: ({ children, ...props }: any) => (
@@ -247,11 +254,28 @@ function getMarkdownComponents() {
// Links: only external links open in a new tab; internal links (/docs/...,
// /samples, #anchors) navigate in place
a: ({ children, href, ...props }: any) => {
- const isExternal = /^https?:\/\//i.test(href ?? '');
+ const resolvedHref = resolveMarkdownLink(href, sourcePath);
+ const isExternal = /^https?:\/\//i.test(resolvedHref ?? '');
+ const linkClassName = 'text-blue-400 hover:text-blue-300 transition-colors';
+
+ // A rewritten href is a resolved document route, so it is known to be an
+ // internal page and is rendered with next/link. That applies the basePath
+ // from next.config identically on the server and in the browser -
+ // NEXT_BASE_PATH is not readable from the client bundle, so neither this
+ // component nor the resolver may prefix the path itself. Any other href
+ // is left exactly as the author wrote it.
+ if (resolvedHref && resolvedHref !== href) {
+ return (
+
+ {children}
+
+ );
+ }
+
return (
diff --git a/app/docs/[section]/[[...slug]]/page.tsx b/app/docs/[section]/[[...slug]]/page.tsx
index 0b3e04f..7a0a5dc 100644
--- a/app/docs/[section]/[[...slug]]/page.tsx
+++ b/app/docs/[section]/[[...slug]]/page.tsx
@@ -322,7 +322,10 @@ export default async function ArticlePage({ params }: PageProps) {
)}
{/* Markdown Content */}
-
+
diff --git a/app/docs/reference/[type]/[category]/[name]/page.tsx b/app/docs/reference/[type]/[category]/[name]/page.tsx
index 6ec0b7b..59542d9 100644
--- a/app/docs/reference/[type]/[category]/[name]/page.tsx
+++ b/app/docs/reference/[type]/[category]/[name]/page.tsx
@@ -47,7 +47,10 @@ export default async function ReferencePage({ params }: { params: Promise<{ type
{/* Markdown Content */}
-
+
);
}
\ No newline at end of file
diff --git a/app/docs/reference/[type]/[category]/page.tsx b/app/docs/reference/[type]/[category]/page.tsx
index 823d1ee..e713656 100644
--- a/app/docs/reference/[type]/[category]/page.tsx
+++ b/app/docs/reference/[type]/[category]/page.tsx
@@ -45,7 +45,10 @@ export default async function CommandReferencePage({ params }: { params: Promise
{description && (
-
+
)}
diff --git a/app/docs/reference/[type]/page.tsx b/app/docs/reference/[type]/page.tsx
index 504a475..0d23053 100644
--- a/app/docs/reference/[type]/page.tsx
+++ b/app/docs/reference/[type]/page.tsx
@@ -44,7 +44,10 @@ export default async function CommandReferencePage({ params }: { params: Promise
{description && (
-
+
)}
diff --git a/app/docs/reference/page.tsx b/app/docs/reference/page.tsx
index e9d2670..f63f0fc 100644
--- a/app/docs/reference/page.tsx
+++ b/app/docs/reference/page.tsx
@@ -30,7 +30,10 @@ export default function Home() {
{description && (
-
+
)}
diff --git a/app/lib/markdownLinks.ts b/app/lib/markdownLinks.ts
new file mode 100644
index 0000000..d7aba28
--- /dev/null
+++ b/app/lib/markdownLinks.ts
@@ -0,0 +1,71 @@
+// Deliberately free of any base-path handling. This module is reachable from a
+// client component, and NEXT_BASE_PATH is not NEXT_PUBLIC_-prefixed, so its
+// value is stripped from the browser bundle: reading it here would produce a
+// prefixed href during the static render and an unprefixed one after
+// hydration. The route returned below is base-path-relative, and the caller
+// renders it with next/link, which applies the basePath configured in
+// next.config on both the server and the client.
+const markdownOrigin = 'https://documentdb.invalid';
+
+const docsRoot = '/docs/';
+
+// content.config.json publishes the docs repository's api-reference/ folder at
+// /docs/reference. Authors write links against the source layout they can see,
+// so a cross-section link naming api-reference has to be mapped onto the
+// section the site actually serves. Every other mapping keeps its folder name.
+const publishedSectionBySourceFolder: Record = {
+ 'api-reference': 'reference',
+};
+
+function applySectionMapping(pathname: string): string {
+ if (!pathname.startsWith(docsRoot)) {
+ return pathname;
+ }
+
+ const rest = pathname.slice(docsRoot.length);
+ const separatorIndex = rest.indexOf('/');
+ const section = separatorIndex === -1 ? rest : rest.slice(0, separatorIndex);
+ const published = publishedSectionBySourceFolder[section];
+
+ if (!published) {
+ return pathname;
+ }
+
+ const remainder = separatorIndex === -1 ? '' : rest.slice(separatorIndex);
+ return `${docsRoot}${published}${remainder}`;
+}
+
+export function resolveMarkdownLink(
+ href: string | undefined,
+ sourcePath: string,
+): string | undefined {
+ if (!href) {
+ return href;
+ }
+
+ if (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//')) {
+ return href;
+ }
+
+ const hrefPath = href.split(/[?#]/, 1)[0];
+ if (!hrefPath.toLowerCase().endsWith('.md')) {
+ return href;
+ }
+
+ const normalizedSourcePath = sourcePath.startsWith('/')
+ ? sourcePath
+ : `/${sourcePath}`;
+ // URL applies the same dot-segment rules as a browser without coupling the
+ // result to the page URL that happens to render this source file.
+ const target = new URL(href, new URL(normalizedSourcePath, markdownOrigin));
+
+ if (target.pathname.toLowerCase().endsWith('/index.md')) {
+ target.pathname = target.pathname.slice(0, -'index.md'.length);
+ } else {
+ target.pathname = `${target.pathname.slice(0, -'.md'.length)}/`;
+ }
+
+ const publishedPathname = applySectionMapping(target.pathname);
+
+ return `${publishedPathname}${target.search}${target.hash}`;
+}
diff --git a/tests/markdownLinks.test.ts b/tests/markdownLinks.test.ts
new file mode 100644
index 0000000..b94cec9
--- /dev/null
+++ b/tests/markdownLinks.test.ts
@@ -0,0 +1,192 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { resolveMarkdownLink } from '../app/lib/markdownLinks';
+
+describe('resolveMarkdownLink', () => {
+ it('maps a sibling Markdown file to its published article route', () => {
+ expect(
+ resolveMarkdownLink(
+ 'functions.md',
+ '/docs/postgres-api/index.md',
+ ),
+ ).toBe('/docs/postgres-api/functions/');
+ });
+
+ it('resolves encoded operator filenames from the source directory', () => {
+ expect(
+ resolveMarkdownLink(
+ './%24bucket.md',
+ '/docs/reference/operators/aggregation/%24bucketauto.md',
+ ),
+ ).toBe('/docs/reference/operators/aggregation/%24bucket/');
+ });
+
+ it('normalizes parent-directory references before publishing the route', () => {
+ expect(
+ resolveMarkdownLink(
+ '../aggregation/aggregate.md',
+ '/docs/reference/commands/query-and-write/getMore.md',
+ ),
+ ).toBe('/docs/reference/commands/aggregation/aggregate/');
+ });
+
+ it('collapses index files to their directory route', () => {
+ expect(
+ resolveMarkdownLink(
+ '../index.md#quick-start',
+ '/docs/getting-started/guides/python.md',
+ ),
+ ).toBe('/docs/getting-started/#quick-start');
+ });
+
+ it('preserves query strings and fragments', () => {
+ expect(
+ resolveMarkdownLink(
+ 'python-setup.md?view=full#connect',
+ '/docs/getting-started/index.md',
+ ),
+ ).toBe('/docs/getting-started/python-setup/?view=full#connect');
+ });
+
+ it.each([
+ 'https://example.com/readme.md',
+ '//example.com/readme.md',
+ '/docs/reference/',
+ '#examples',
+ 'mailto:guide.md',
+ ])('leaves non-source-document link %s unchanged', (href) => {
+ expect(
+ resolveMarkdownLink(href, '/docs/getting-started/index.md'),
+ ).toBe(href);
+ });
+
+ // content.config.json maps the docs repository's api-reference/ folder onto
+ // /docs/reference. Authors link against the folder they can see, so without
+ // the mapping a cross-section link resolves to a route that does not exist.
+ it('maps the api-reference source folder onto the published reference section', () => {
+ expect(
+ resolveMarkdownLink(
+ '../api-reference/operators/aggregation/%24limit.md',
+ '/docs/getting-started/index.md',
+ ),
+ ).toBe('/docs/reference/operators/aggregation/%24limit/');
+ });
+
+ it('maps api-reference when the link climbs out of the reference section itself', () => {
+ expect(
+ resolveMarkdownLink(
+ '../../../api-reference/commands/query-and-write/find.md',
+ '/docs/reference/operators/aggregation/%24search.md',
+ ),
+ ).toBe('/docs/reference/commands/query-and-write/find/');
+ });
+
+ it('leaves sections that publish under their own folder name alone', () => {
+ expect(
+ resolveMarkdownLink(
+ '../postgres-api/functions.md',
+ '/docs/getting-started/index.md',
+ ),
+ ).toBe('/docs/postgres-api/functions/');
+ });
+});
+
+// The resolver runs inside a client component. NEXT_BASE_PATH is not
+// NEXT_PUBLIC_-prefixed, so its value is stripped from the browser bundle:
+// anything that reads it here yields a prefixed href during the static render
+// and an unprefixed one after hydration, which is invisible in the exported
+// HTML and only shows up in a browser. The route must therefore stay
+// base-path-relative, and next/link applies the prefix on both sides.
+describe('resolveMarkdownLink under a configured base path', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ });
+
+ async function importWithBasePath(basePath: string) {
+ vi.resetModules();
+ vi.stubEnv('NEXT_BASE_PATH', basePath);
+ return (await import('../app/lib/markdownLinks')).resolveMarkdownLink;
+ }
+
+ it('returns an unprefixed route, leaving the base path to next/link', async () => {
+ const resolve = await importWithBasePath('/preview');
+
+ expect(resolve('functions.md', '/docs/postgres-api/index.md')).toBe(
+ '/docs/postgres-api/functions/',
+ );
+ });
+
+ it('leaves a mapped cross-section route unprefixed as well', async () => {
+ const resolve = await importWithBasePath('/preview');
+
+ expect(
+ resolve(
+ '../api-reference/operators/aggregation/%24limit.md',
+ '/docs/getting-started/index.md',
+ ),
+ ).toBe('/docs/reference/operators/aggregation/%24limit/');
+ });
+
+ it('keeps the query and fragment intact', async () => {
+ const resolve = await importWithBasePath('preview');
+
+ expect(
+ resolve('python-setup.md?view=full#connect', '/docs/getting-started/index.md'),
+ ).toBe('/docs/getting-started/python-setup/?view=full#connect');
+ });
+
+ it('leaves links it does not rewrite untouched', async () => {
+ const resolve = await importWithBasePath('/preview');
+
+ expect(resolve('#examples', '/docs/getting-started/index.md')).toBe('#examples');
+ expect(
+ resolve('https://example.com/readme.md', '/docs/getting-started/index.md'),
+ ).toBe('https://example.com/readme.md');
+ });
+
+ it('produces the same route whether or not a base path is configured', async () => {
+ const withPreview = await importWithBasePath('/preview');
+ const resolvedWithPreview = withPreview(
+ 'functions.md',
+ '/docs/postgres-api/index.md',
+ );
+
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ const { resolveMarkdownLink: withoutPreview } = await import(
+ '../app/lib/markdownLinks'
+ );
+
+ expect(resolvedWithPreview).toBe(
+ withoutPreview('functions.md', '/docs/postgres-api/index.md'),
+ );
+ });
+});
+
+// A build-level assertion on the exported HTML cannot catch this: the export is
+// rendered server-side, where the variable is readable, so the emitted markup
+// looks correct and only the hydrated page is wrong. Guard it at the source
+// instead, which is where the mistake is actually made.
+describe('client-reachable modules and the private base path', () => {
+ const clientReachableSources = [
+ 'app/lib/markdownLinks.ts',
+ 'app/components/Markdown.tsx',
+ ];
+
+ it.each(clientReachableSources)(
+ '%s does not read NEXT_BASE_PATH or import sitePath',
+ async (relativePath) => {
+ const { readFile } = await import('node:fs/promises');
+ const { fileURLToPath } = await import('node:url');
+ const source = await readFile(
+ fileURLToPath(new URL(`../${relativePath}`, import.meta.url)),
+ 'utf8',
+ );
+ const code = source.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, '');
+
+ expect(code).not.toContain('NEXT_BASE_PATH');
+ expect(code).not.toContain('sitePath');
+ expect(code).not.toContain('withBasePath');
+ },
+ );
+});