mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
refactor: Move URL prefixing to AST (#47074)
This commit is contained in:
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)
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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: .'
|
||||
expect(prefixInternalLinks(input, BASE)).toBe(input)
|
||||
})
|
||||
|
||||
it('rewrites a link adjacent to an image without touching the image', () => {
|
||||
expect(prefixInternalLinks(' and [home](/dashboard)', BASE)).toBe(
|
||||
' and [home](https://supabase.com/dashboard)'
|
||||
)
|
||||
it('does not rewrite image URLs', () => {
|
||||
expect(linkUrls('')).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'])
|
||||
})
|
||||
})
|
||||
@@ -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 (``), 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
|
||||
}
|
||||
Reference in new issue
Block a user