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'); + }, + ); +});