mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## Problem We want to upgrade to react 19. However some libraries aren't compatible with it. Besides, `next-mdx-remote` is now archived and not maintained anymore. ## Solution The [NextJS documentation)[https://nextjs.org/docs/15/app/guides/mdx#remote-mdx] suggest using [`next-mdx-remote-client`](https://github.com/ipikuka/next-mdx-remote-client) which was a fork of `next-mdx-remote`. - [x] migrate `apps/www` from `next-mdx-remote` to `next-mdx-remote-client` - [x] migrate `apps/www` from `next-mdx-remote` to `next-mdx-remote-client` I haven't noticed any change in the pages. When upgrading to react 19, we'll have to use v2 of `next-mdx-remote-client`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Switched MDX rendering/serialization to a newer client-focused implementation across docs and site for improved compatibility. * **Bug Fixes** * Improved handling of serialization errors so MDX failures render clear fallback messages instead of breaking pages. * **Chores** * Updated local environment template value for the public anonymous key. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
207 lines
6.8 KiB
TypeScript
207 lines
6.8 KiB
TypeScript
import { remarkCodeHike, type CodeHikeConfig } from '@code-hike/mdx'
|
|
import { preprocessMdxWithCodeTabs } from '~/components/CodeTabs'
|
|
import codeHikeTheme from 'config/code-hike.theme.json' with { type: 'json' }
|
|
import { serialize } from 'next-mdx-remote-client/serialize'
|
|
import rehypeSlug from 'rehype-slug'
|
|
import remarkGfm from 'remark-gfm'
|
|
|
|
/**
|
|
* Applies a string transform only to content outside fenced code blocks
|
|
* (``` or ~~~) so pre-parse fixes never corrupt code examples.
|
|
*/
|
|
function transformOutsideCodeFences(source: string, transform: (chunk: string) => string): string {
|
|
const fenceRe = /(^(?:`{3,}|~{3,})[^\n]*\n[\s\S]*?\n(?:`{3,}|~{3,})[ \t]*$)/gm
|
|
const parts = source.split(fenceRe)
|
|
// split() with a capture group: even indices are outside fences, odd are fence blocks
|
|
return parts.map((part, i) => (i % 2 === 0 ? transform(part) : part)).join('')
|
|
}
|
|
|
|
// mdx2 needs self-closing tags.
|
|
// dragging an image onto a GitHub discussion creates an <img>
|
|
// we need to fix this before running them through mdx
|
|
// also checks for <br> and <hr>
|
|
function addSelfClosingTags(htmlString: string): string {
|
|
// Handle cases where htmlString might be undefined, null, or not a string
|
|
if (!htmlString || typeof htmlString !== 'string') {
|
|
return ''
|
|
}
|
|
return transformOutsideCodeFences(htmlString, (chunk) =>
|
|
chunk.replace(/<img[^>]*>|<br[^>]*>|<hr[^>]*>/g, (match) =>
|
|
match.endsWith('/>') ? match : match.slice(0, -1) + ' />'
|
|
)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Remark plugin: converts raw HTML `<img>` nodes and MDX JSX `<img />` elements
|
|
* to standard markdown `image` AST nodes so they go through the mdxComponents
|
|
* img rendering pipeline.
|
|
*
|
|
* Operating on the AST means code/inlineCode nodes are already separate subtrees
|
|
* and are never visited, so code fences are never affected.
|
|
*/
|
|
function remarkNormalizeHtmlImages() {
|
|
return function transformer(tree: any) {
|
|
const getAttrFromString = (attrs: string, name: string) => {
|
|
const m = attrs.match(new RegExp(`${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i'))
|
|
return (m?.[2] ?? m?.[3] ?? '').trim()
|
|
}
|
|
|
|
const walk = (node: any, parent: any, index: number) => {
|
|
// Never descend into code nodes — content there is literal text.
|
|
if (node.type === 'code' || node.type === 'inlineCode') return
|
|
|
|
if (parent !== null) {
|
|
// Standard markdown / non-MDX: raw HTML blocks become `html` nodes.
|
|
if (node.type === 'html') {
|
|
const match = /^\s*<img\b([^>]*)\/?>\s*$/i.exec(node.value)
|
|
if (match) {
|
|
const src = getAttrFromString(match[1], 'src')
|
|
if (src) {
|
|
const alt = getAttrFromString(match[1], 'alt')
|
|
parent.children[index] = { type: 'image', url: src, alt, title: null }
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// MDX mode: remark-mdx parses <img /> as mdxJsxFlowElement / mdxJsxTextElement.
|
|
if (
|
|
(node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
|
|
node.name === 'img'
|
|
) {
|
|
const getJsxAttr = (name: string): string => {
|
|
const attr = node.attributes?.find(
|
|
(a: any) => a.type === 'mdxJsxAttribute' && a.name === name
|
|
)
|
|
return typeof attr?.value === 'string' ? attr.value : ''
|
|
}
|
|
const src = getJsxAttr('src')
|
|
if (src) {
|
|
const alt = getJsxAttr('alt')
|
|
parent.children[index] = { type: 'image', url: src, alt, title: null }
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if (node.children) {
|
|
for (let i = 0; i < node.children.length; i++) {
|
|
walk(node.children[i], node, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(tree, null, 0)
|
|
}
|
|
}
|
|
|
|
type TocItem = { content: string; slug: string; lvl: number }
|
|
|
|
function createRemarkCollectToc(maxDepth: number) {
|
|
return function remarkCollectToc() {
|
|
return function transformer(tree: any) {
|
|
const items: TocItem[] = []
|
|
|
|
const getText = (node: any): string => {
|
|
if (!node) return ''
|
|
if (node.type === 'text') return node.value || ''
|
|
if (Array.isArray(node.children)) return node.children.map(getText).join('')
|
|
return ''
|
|
}
|
|
|
|
const slugify = (input: string) =>
|
|
input
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[`~!@#$%^&*()+=|{}\[\]\\:";'<>?,./]+/g, '')
|
|
.replace(/\s+/g, '-')
|
|
|
|
const walk = (node: any) => {
|
|
if (node.type === 'heading' && typeof node.depth === 'number') {
|
|
if (node.depth <= maxDepth) {
|
|
const text = getText(node)
|
|
if (text) {
|
|
items.push({ content: text, slug: slugify(text), lvl: node.depth })
|
|
}
|
|
}
|
|
}
|
|
if (node.children) {
|
|
for (const child of node.children) walk(child)
|
|
}
|
|
}
|
|
|
|
walk(tree)
|
|
|
|
// Attach to tree data for retrieval post-serialize
|
|
if (!tree.data) {
|
|
tree.data = {}
|
|
}
|
|
tree.data.__collectedToc = items
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function mdxSerialize(source: string, options?: { tocDepth?: number }) {
|
|
const formattedSource = addSelfClosingTags(source)
|
|
// Preprocess MDX to handle CodeTabs transformation
|
|
const preprocessedSource = await preprocessMdxWithCodeTabs(formattedSource)
|
|
const codeHikeOptions: CodeHikeConfig = {
|
|
theme: codeHikeTheme,
|
|
lineNumbers: true,
|
|
showCopyButton: true,
|
|
skipLanguages: ['mermaid'],
|
|
autoImport: false,
|
|
}
|
|
|
|
const tocDepth = options?.tocDepth ?? 2
|
|
let collectedToc: TocItem[] = []
|
|
|
|
const mdxSource = await serialize({
|
|
source: preprocessedSource,
|
|
options: {
|
|
scope: {
|
|
toc: { content: '', json: collectedToc },
|
|
},
|
|
mdxOptions: {
|
|
remarkPlugins: [
|
|
remarkNormalizeHtmlImages,
|
|
[remarkCodeHike, codeHikeOptions],
|
|
remarkGfm,
|
|
// Collect headings into a simple TOC structure
|
|
() => {
|
|
const plugin = createRemarkCollectToc(tocDepth)
|
|
const transformer = (plugin as any)()
|
|
return (tree: any) => {
|
|
transformer(tree)
|
|
if (tree?.data?.__collectedToc) {
|
|
collectedToc = tree.data.__collectedToc as TocItem[]
|
|
}
|
|
}
|
|
},
|
|
],
|
|
rehypePlugins: [
|
|
// @ts-ignore
|
|
rehypeSlug, // add IDs to any h1-h6 tag that doesn't have one, using a slug made from its text
|
|
],
|
|
},
|
|
},
|
|
})
|
|
|
|
// Expose TOC via scope for consumers (keeps function signature stable)
|
|
const minLvl = collectedToc.length > 0 ? Math.min(...collectedToc.map((h) => h.lvl)) : 1
|
|
const tocMarkdown = collectedToc
|
|
.map((h) => `${' '.repeat(Math.max(0, h.lvl - minLvl))}- [${h.content}](#${h.slug})`)
|
|
.join('\n')
|
|
|
|
mdxSource.scope = {
|
|
...(mdxSource.scope || {}),
|
|
toc: {
|
|
content: tocMarkdown,
|
|
json: collectedToc,
|
|
},
|
|
}
|
|
|
|
return mdxSource
|
|
}
|