diff --git a/apps/docs/internals/generate-guides-markdown.ts b/apps/docs/internals/generate-guides-markdown.ts index b6a541e5b14..72aeb82ded4 100644 --- a/apps/docs/internals/generate-guides-markdown.ts +++ b/apps/docs/internals/generate-guides-markdown.ts @@ -10,10 +10,9 @@ import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx' import { toMarkdown } from 'mdast-util-to-markdown' import { gfm } from 'micromark-extension-gfm' import { mdxjs } from 'micromark-extension-mdxjs' - -import { getInternalLinkBaseUrl, prefixInternalLinks } from './internal-links' import { Admonition } from './markdown-schema/Admonition' import { ComputeDiskLimitsTable } from './markdown-schema/ComputeDiskLimitsTable' +import { addBaseUrlPrefix } from './internal-links' import { Link } from './markdown-schema/Link' import { MetricsStackCards } from './markdown-schema/MetricsStackCards' import { Panel } from './markdown-schema/Panel' @@ -144,14 +143,15 @@ const SCHEMA: ComponentSchema = { SharedData, } -async function generateOne(filePath: string, linkBaseUrl: string): Promise { +async function generateOne(filePath: string): Promise { const raw = await fs.readFile(filePath, 'utf8') const { content, data } = matter(raw) const tree = parseMdx(content) await inlinePartials(tree) + addBaseUrlPrefix(tree) applySchema(tree, SCHEMA) - const body = prefixInternalLinks(serializeMdx(tree), linkBaseUrl) + const body = serializeMdx(tree) const headerParts: string[] = [] if (data.title) headerParts.push(`# ${data.title}`) @@ -167,7 +167,6 @@ async function generateOne(filePath: string, linkBaseUrl: string): Promise( path.join(process.cwd(), 'content/reference/javascript/v2/typeSpec.json') ) - const linkBaseUrl = getInternalLinkBaseUrl() await Promise.all( REFERENCES.map(async (ref) => { @@ -407,7 +410,17 @@ async function generate() { output = await renderCli(ref) break } - await fs.writeFile(path.join(OUT_DIR, ref.outFile), prefixInternalLinks(output, linkBaseUrl)) + const tree = fromMarkdown(output, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }) + addBaseUrlPrefix(tree) + const prefixed = toMarkdown(tree, { + extensions: [gfmToMarkdown()], + bullet: '-', + listItemIndent: 'one', + }) + await fs.writeFile(path.join(OUT_DIR, ref.outFile), prefixed) }) ) diff --git a/apps/docs/internals/internal-links.test.ts b/apps/docs/internals/internal-links.test.ts index 54a4bf783e5..d2bc19999d9 100644 --- a/apps/docs/internals/internal-links.test.ts +++ b/apps/docs/internals/internal-links.test.ts @@ -1,6 +1,7 @@ +import { fromMarkdown } from 'mdast-util-from-markdown' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getInternalLinkBaseUrl, prefixInternalLinks, withDocsBasePath } from './internal-links' +import { addBaseUrlPrefix, getInternalLinkBaseUrl, withDocsBasePath } from './internal-links' describe('withDocsBasePath', () => { it('prepends /docs to a root-relative href', () => { @@ -95,147 +96,46 @@ describe('getInternalLinkBaseUrl', () => { }) }) -describe('prefixInternalLinks', () => { - const BASE = 'https://supabase.com' +describe('addBaseUrlPrefix', () => { + const ORIGINAL_ENV = process.env - it('returns content unchanged when baseUrl is empty', () => { - const input = 'See [Dashboard](/dashboard/foo).' - expect(prefixInternalLinks(input, '')).toBe(input) + beforeEach(() => { + process.env = { ...ORIGINAL_ENV, VERCEL_ENV: 'production' } }) - it('prepends baseUrl to a root-relative link', () => { - expect(prefixInternalLinks('See [Dashboard](/dashboard/foo).', BASE)).toBe( - 'See [Dashboard](https://supabase.com/dashboard/foo).' - ) + afterEach(() => { + process.env = ORIGINAL_ENV }) - it('rewrites multiple links on the same line', () => { - const input = 'A [one](/a) and [two](/b/c) here.' - expect(prefixInternalLinks(input, BASE)).toBe( - 'A [one](https://supabase.com/a) and [two](https://supabase.com/b/c) here.' - ) + const linkUrls = (markdown: string): string[] => { + const tree = fromMarkdown(markdown) + addBaseUrlPrefix(tree) + const urls: string[] = [] + const visit = (n: any) => { + if (n.type === 'link') urls.push(n.url) + if (Array.isArray(n.children)) n.children.forEach(visit) + } + visit(tree) + return urls + } + + it('prepends baseUrl to root-relative link URLs', () => { + expect(linkUrls('[home](/foo)')).toEqual(['https://supabase.com/foo']) }) - it('preserves query strings and fragments', () => { - expect(prefixInternalLinks('[link](/foo?bar=1&baz=2#section)', BASE)).toBe( - '[link](https://supabase.com/foo?bar=1&baz=2#section)' - ) + it('leaves absolute, anchor, and protocol-relative URLs alone', () => { + expect(linkUrls('[a](https://x.com) [b](#h) [c](//cdn/x)')).toEqual([ + 'https://x.com', + '#h', + '//cdn/x', + ]) }) - it('leaves absolute http(s) links alone', () => { - const input = 'See [GitHub](https://github.com/supabase).' - expect(prefixInternalLinks(input, BASE)).toBe(input) - }) - - it('leaves anchor-only links alone', () => { - const input = 'Jump to [section](#installation).' - expect(prefixInternalLinks(input, BASE)).toBe(input) - }) - - it('leaves mailto and other schemes alone', () => { - const input = 'Email [us](mailto:team@example.com) or [call](tel:+1234).' - expect(prefixInternalLinks(input, BASE)).toBe(input) - }) - - it('leaves explicitly relative links (./, ../) alone', () => { - const input = 'See [sibling](./sibling) and [parent](../parent).' - expect(prefixInternalLinks(input, BASE)).toBe(input) - }) - - it('leaves protocol-relative (//host) URLs alone', () => { - const input = 'CDN [asset](//cdn.example.com/img.png).' - expect(prefixInternalLinks(input, BASE)).toBe(input) - }) - - it('does not rewrite image syntax', () => { - const input = 'An image: ![alt text](/static/foo.png).' - expect(prefixInternalLinks(input, BASE)).toBe(input) - }) - - it('rewrites a link adjacent to an image without touching the image', () => { - expect(prefixInternalLinks('![logo](/logo.png) and [home](/dashboard)', BASE)).toBe( - '![logo](/logo.png) and [home](https://supabase.com/dashboard)' - ) + it('does not rewrite image URLs', () => { + expect(linkUrls('![alt](/img.png)')).toEqual([]) }) it('skips links inside fenced code blocks', () => { - const input = [ - 'Before: [yes](/touch-me).', - '', - '```md', - '[ignore me](/leave-alone)', - '```', - '', - 'After: [also yes](/touch-me-too).', - ].join('\n') - - expect(prefixInternalLinks(input, BASE)).toBe( - [ - 'Before: [yes](https://supabase.com/touch-me).', - '', - '```md', - '[ignore me](/leave-alone)', - '```', - '', - 'After: [also yes](https://supabase.com/touch-me-too).', - ].join('\n') - ) - }) - - it('handles multiple fenced code blocks correctly', () => { - const input = [ - '[a](/a)', - '```', - '[skip1](/skip1)', - '```', - '[b](/b)', - '```ts', - '[skip2](/skip2)', - '```', - '[c](/c)', - ].join('\n') - - expect(prefixInternalLinks(input, BASE)).toBe( - [ - '[a](https://supabase.com/a)', - '```', - '[skip1](/skip1)', - '```', - '[b](https://supabase.com/b)', - '```ts', - '[skip2](/skip2)', - '```', - '[c](https://supabase.com/c)', - ].join('\n') - ) - }) - - it('rewrites links with empty text', () => { - expect(prefixInternalLinks('[](/foo)', BASE)).toBe('[](https://supabase.com/foo)') - }) - - it('uses any baseUrl passed in, not just supabase.com', () => { - expect(prefixInternalLinks('[x](/y)', 'https://branch-deploy.vercel.app')).toBe( - '[x](https://branch-deploy.vercel.app/y)' - ) - }) - - it('is a no-op when there are no matching links', () => { - const input = '# Title\n\nJust prose, no links.' - expect(prefixInternalLinks(input, BASE)).toBe(input) - }) - - it('handles an unclosed code fence by leaving the unclosed portion untouched', () => { - // A `split(/(```...```)/)` only pairs complete fences; an unclosed fence - // means everything after it stays in the trailing prose segment. Document - // that behavior rather than promising to parse malformed markdown. - const input = ['[before](/before)', '```', '[inside-unclosed](/inside)'].join('\n') - expect(prefixInternalLinks(input, BASE)).toBe( - [ - '[before](https://supabase.com/before)', - '```', - '[inside-unclosed](https://supabase.com/inside)', - ].join('\n') - ) + expect(linkUrls('```\n[x](/x)\n```\n\n[y](/y)')).toEqual(['https://supabase.com/y']) }) }) diff --git a/apps/docs/internals/internal-links.ts b/apps/docs/internals/internal-links.ts index ae19af20595..54bb5d5ac19 100644 --- a/apps/docs/internals/internal-links.ts +++ b/apps/docs/internals/internal-links.ts @@ -1,3 +1,6 @@ +import { Root } from 'mdast' +import { visit } from 'unist-util-visit' + const DOCS_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || '/docs' /** @@ -34,24 +37,14 @@ export function getInternalLinkBaseUrl(): string { return '' } -/** - * Rewrite root-relative markdown links by prepending `baseUrl`: - * `[text](/foo)` → `[text](${baseUrl}/foo)` - * - * Skips fenced code blocks, image syntax (`![alt](...)`), protocol-relative - * URLs (`//host/...`), and non-root-relative targets (`http://`, `mailto:`, - * `#anchor`, `./`, `../`). - */ -export function prefixInternalLinks(content: string, baseUrl: string): string { - if (!baseUrl) return content - const segments = content.split(/(```[\s\S]*?```)/g) - return segments - .map((seg, i) => { - if (i % 2 === 1) return seg - return seg.replace(/(? { - if (url.startsWith('//')) return match - return `${text}(${baseUrl}${url})` - }) - }) - .join('') +export function addBaseUrlPrefix(tree: Root) { + const baseUrl = getInternalLinkBaseUrl() + + visit(tree, 'link', (node) => { + if (node.url.startsWith('/') && !node.url.startsWith('//')) { + node.url = baseUrl + node.url + } + }) + + return tree }