From 04ce3ada84b6f253f4e585463e4933367ddb961d Mon Sep 17 00:00:00 2001 From: Guanzhou Song Date: Mon, 3 Aug 2026 19:58:40 -0400 Subject: [PATCH 1/3] Resolve Markdown document links from source paths Signed-off-by: Guanzhou Song Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/components/Markdown.tsx | 21 ++++--- app/docs/[section]/[[...slug]]/page.tsx | 5 +- .../[type]/[category]/[name]/page.tsx | 5 +- app/docs/reference/[type]/[category]/page.tsx | 5 +- app/docs/reference/[type]/page.tsx | 5 +- app/docs/reference/page.tsx | 5 +- app/lib/markdownLinks.ts | 34 +++++++++++ tests/markdownLinks.test.ts | 61 +++++++++++++++++++ 8 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 app/lib/markdownLinks.ts create mode 100644 tests/markdownLinks.test.ts diff --git a/app/components/Markdown.tsx b/app/components/Markdown.tsx index 84d1c72..f574a64 100644 --- a/app/components/Markdown.tsx +++ b/app/components/Markdown.tsx @@ -6,8 +6,14 @@ 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 +28,7 @@ export default function Markdown({ content }: { content: string }) {
{section} @@ -42,7 +48,7 @@ export default function Markdown({ content }: { content: string }) { {sectionContent} @@ -52,12 +58,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,10 +253,11 @@ 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 ?? ''); return ( +
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..e6c445a --- /dev/null +++ b/app/lib/markdownLinks.ts @@ -0,0 +1,34 @@ +const markdownOrigin = 'https://documentdb.invalid'; + +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)}/`; + } + + return `${target.pathname}${target.search}${target.hash}`; +} diff --git a/tests/markdownLinks.test.ts b/tests/markdownLinks.test.ts new file mode 100644 index 0000000..f9cceef --- /dev/null +++ b/tests/markdownLinks.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } 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); + }); +}); From 91594e077202fd2baac8b348c9670d7520e298e0 Mon Sep 17 00:00:00 2001 From: Guanzhou Song Date: Mon, 3 Aug 2026 20:18:32 -0400 Subject: [PATCH 2/3] Map api-reference onto reference, and apply the base path Two gaps in the resolver, both found in review. A cross-section link naming api-reference resolved to a route that does not exist. content.config.json publishes the docs repository's api-reference/ folder at /docs/reference, and the source paths callers pass are already in website space, so a link written the only way an author can write it - ../api-reference/operators/aggregation/$limit.md, against the folder they can see - resolved to /docs/api-reference/... and 404'd. That is the one folder whose published name differs; every other section keeps its own, so the mapping is a single entry rather than a copy of the config. The resolved route also skipped NEXT_BASE_PATH. Markdown links render as plain anchors rather than next/link, so nothing applies the base path for us, and a subpath deployment would have emitted /docs/... - correct only at the domain root. withBasePath now wraps the path, leaving the query and fragment after it. Navbar already does this from a client component, so this follows the pattern the codebase settled on rather than introducing one. Covered both: the mapping from an article page and from inside the reference section, a section that is not remapped, and the base path across a plain route, a mapped route, one carrying a query and fragment, and links the resolver declines to touch. The base path tests re-import the module after stubbing the environment, since sitePath reads it once at module scope. npm install fails against the registry proxy on this machine, so the vitest suite is left to CI. The resolver itself was exercised directly under node with type stripping, over every case above, with and without a base path configured. --- app/lib/markdownLinks.ts | 36 +++++++++++++++- tests/markdownLinks.test.ts | 83 ++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/app/lib/markdownLinks.ts b/app/lib/markdownLinks.ts index e6c445a..72b783d 100644 --- a/app/lib/markdownLinks.ts +++ b/app/lib/markdownLinks.ts @@ -1,5 +1,35 @@ +import { withBasePath } from '../services/sitePath'; + 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, @@ -30,5 +60,9 @@ export function resolveMarkdownLink( target.pathname = `${target.pathname.slice(0, -'.md'.length)}/`; } - return `${target.pathname}${target.search}${target.hash}`; + const publishedPathname = applySectionMapping(target.pathname); + + // Rendered as a plain anchor rather than next/link, so the base path is not + // applied for us - a subpath deployment needs it added here. + return `${withBasePath(publishedPathname)}${target.search}${target.hash}`; } diff --git a/tests/markdownLinks.test.ts b/tests/markdownLinks.test.ts index f9cceef..b7266b0 100644 --- a/tests/markdownLinks.test.ts +++ b/tests/markdownLinks.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { resolveMarkdownLink } from '../app/lib/markdownLinks'; describe('resolveMarkdownLink', () => { @@ -58,4 +58,85 @@ describe('resolveMarkdownLink', () => { 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/'); + }); +}); + +describe('resolveMarkdownLink with a configured base path', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + // sitePath reads NEXT_BASE_PATH once at module scope, so the module has to be + // re-imported after the environment is stubbed. + async function importWithBasePath(basePath: string) { + vi.resetModules(); + vi.stubEnv('NEXT_BASE_PATH', basePath); + return (await import('../app/lib/markdownLinks')).resolveMarkdownLink; + } + + it('prefixes the resolved route, since the anchor is not a next/link', async () => { + const resolve = await importWithBasePath('/preview'); + + expect(resolve('functions.md', '/docs/postgres-api/index.md')).toBe( + '/preview/docs/postgres-api/functions/', + ); + }); + + it('prefixes a mapped cross-section route as well', async () => { + const resolve = await importWithBasePath('/preview'); + + expect( + resolve( + '../api-reference/operators/aggregation/%24limit.md', + '/docs/getting-started/index.md', + ), + ).toBe('/preview/docs/reference/operators/aggregation/%24limit/'); + }); + + it('keeps the query and fragment after the prefixed path', async () => { + const resolve = await importWithBasePath('preview'); + + expect( + resolve('python-setup.md?view=full#connect', '/docs/getting-started/index.md'), + ).toBe('/preview/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'); + }); }); From 4ab88882c6670bf64440f6f5612dad650901ae63 Mon Sep 17 00:00:00 2001 From: Guanzhou Song Date: Mon, 3 Aug 2026 20:41:44 -0400 Subject: [PATCH 3/3] Leave the base path to next/link instead of reading it in the browser The previous fix was wrong in a way its tests could not see. Markdown is a client component, so markdownLinks ships in the browser bundle, and NEXT_BASE_PATH is not NEXT_PUBLIC_-prefixed: Next strips it from that bundle. Under a configured base path the static render - which runs in node, where the variable exists - emitted /preview/docs/..., and the same code after hydration emitted /docs/..., so the href changed under the reader and React had a mismatch to reconcile. Nothing in the exported HTML shows this, because the export is produced by the server-side render that reads the variable correctly. The tests passed for the same reason: vitest runs in node. The resolver is pure again and returns a base-path-relative route. The anchor renderer now uses next/link for a rewritten href, which is by definition an internal document route, and next/link applies the basePath from next.config on both the server and the client. Hrefs the resolver leaves alone still render as plain anchors, so external links, fragments and hand-written absolute paths behave exactly as before. Coverage follows the same reasoning. The base-path tests now assert the route is unchanged whether or not NEXT_BASE_PATH is set, and a source guard asserts neither client-reachable module mentions NEXT_BASE_PATH, sitePath or withBasePath. A build-level check would not have caught this and would not catch a regression: the markup on disk is correct in both the broken and the fixed version. Proving the browser behaviour would need hydration testing, which the project has no harness for today - jsdom and testing-library are not dependencies - so the guard is placed where the mistake is made instead. Note Navbar has the same latent defect: it calls withBasePath at module scope in a client component, for the blogs anchor and the logo image. Left alone here, since it is a different surface and this branch should not grow to cover it. --- app/components/Markdown.tsx | 19 ++++++++++- app/lib/markdownLinks.ts | 13 ++++--- tests/markdownLinks.test.ts | 68 ++++++++++++++++++++++++++++++++----- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/app/components/Markdown.tsx b/app/components/Markdown.tsx index f574a64..3849e3a 100644 --- a/app/components/Markdown.tsx +++ b/app/components/Markdown.tsx @@ -2,6 +2,7 @@ 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'; @@ -255,10 +256,26 @@ function getMarkdownComponents(sourcePath: string) { a: ({ children, href, ...props }: any) => { 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/lib/markdownLinks.ts b/app/lib/markdownLinks.ts index 72b783d..d7aba28 100644 --- a/app/lib/markdownLinks.ts +++ b/app/lib/markdownLinks.ts @@ -1,5 +1,10 @@ -import { withBasePath } from '../services/sitePath'; - +// 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/'; @@ -62,7 +67,5 @@ export function resolveMarkdownLink( const publishedPathname = applySectionMapping(target.pathname); - // Rendered as a plain anchor rather than next/link, so the base path is not - // applied for us - a subpath deployment needs it added here. - return `${withBasePath(publishedPathname)}${target.search}${target.hash}`; + return `${publishedPathname}${target.search}${target.hash}`; } diff --git a/tests/markdownLinks.test.ts b/tests/markdownLinks.test.ts index b7266b0..b94cec9 100644 --- a/tests/markdownLinks.test.ts +++ b/tests/markdownLinks.test.ts @@ -90,29 +90,33 @@ describe('resolveMarkdownLink', () => { }); }); -describe('resolveMarkdownLink with a configured base path', () => { +// 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(); }); - // sitePath reads NEXT_BASE_PATH once at module scope, so the module has to be - // re-imported after the environment is stubbed. async function importWithBasePath(basePath: string) { vi.resetModules(); vi.stubEnv('NEXT_BASE_PATH', basePath); return (await import('../app/lib/markdownLinks')).resolveMarkdownLink; } - it('prefixes the resolved route, since the anchor is not a next/link', async () => { + 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( - '/preview/docs/postgres-api/functions/', + '/docs/postgres-api/functions/', ); }); - it('prefixes a mapped cross-section route as well', async () => { + it('leaves a mapped cross-section route unprefixed as well', async () => { const resolve = await importWithBasePath('/preview'); expect( @@ -120,15 +124,15 @@ describe('resolveMarkdownLink with a configured base path', () => { '../api-reference/operators/aggregation/%24limit.md', '/docs/getting-started/index.md', ), - ).toBe('/preview/docs/reference/operators/aggregation/%24limit/'); + ).toBe('/docs/reference/operators/aggregation/%24limit/'); }); - it('keeps the query and fragment after the prefixed path', async () => { + 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('/preview/docs/getting-started/python-setup/?view=full#connect'); + ).toBe('/docs/getting-started/python-setup/?view=full#connect'); }); it('leaves links it does not rewrite untouched', async () => { @@ -139,4 +143,50 @@ describe('resolveMarkdownLink with a configured base path', () => { 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'); + }, + ); });