refactor: Move URL prefixing to AST (#47074)

This commit is contained in:
Jeremias Menichelli authored and GitHub committed 2026-06-22 12:11:55 +02:00
1 parent 57ab28c2f0
commit fdd5b11dff
4 files changed
+65 -160

No files matched your search

@@ -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<string> {
async function generateOne(filePath: string): Promise<string> {
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<strin
async function generate() {
const files = await globby(['content/guides/**/!(_)*.mdx'])
const linkBaseUrl = getInternalLinkBaseUrl()
let warnings = 0
await Promise.all(
@@ -181,7 +180,7 @@ async function generate() {
let output: string
try {
output = await generateOne(filePath, linkBaseUrl)
output = await generateOne(filePath)
} catch (err) {
warnings++
console.warn(
@@ -2,8 +2,12 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
import yaml from 'js-yaml'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm'
import { toMarkdown } from 'mdast-util-to-markdown'
import { gfm } from 'micromark-extension-gfm'
import { getInternalLinkBaseUrl, prefixInternalLinks } from './internal-links'
import { addBaseUrlPrefix } from './internal-links'
const GENERATED = path.join(process.cwd(), 'features/docs/generated')
const OUT_DIR = path.join(process.cwd(), 'public/markdown/reference')
@@ -388,7 +392,6 @@ async function generate() {
const sharedTypeSpec = await readJson<TypeSpec>(
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)
})
)
+31 -131
View File
@@ -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'])
})
})
+13 -20
View File
@@ -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(/(?<!!)(\[[^\]]*\])\((\/[^)\s]*)\)/g, (match, text, url) => {
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
}