mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## What kind of change does this PR introduce? follow-up to #50235 to reduce reference page payloads and cold rendering overhead ## What is the current behavior? reference pages ship a large rsc payload inside the html _ most of it is duplication rather than content along with shiki that writes ~30 character css variable name for every syntax token making the page heavy in some cases ## What is the new behavior? - moves repeated styles into shared css and uses compact, namespaced token classes - renders details icons inside the client trigger - follows shiki’s guidance to [reuse one highlighter](https://shiki.style/guide/best-performance#cache-the-highlighter-instance) and [load languages on demand](https://shiki.style/guide/best-performance#use-shorthands) `page size` page | before | after | change -- | -- | -- | -- javascript | 10.61 mb | 8.28 mb | -21.9% dart | 4.59 mb | 4.13 mb | -10.1% python | 4.38 mb | 3.73 mb | -14.9% swift | 2.87 mb | 2.56 mb | -10.9% server | 1.59 mb | 1.35 mb | -15.2% kotlin | 3.42 mb | 3.17 mb | -7.2% `cold initialization` language | before | after | reduction -- | -- | -- | -- bash | 2,180 ms | 23 ms | 98.95% javascript | 2,245 ms | 38 ms | 98.29% ## Additional context measured on a local production build which uses the checked in generated content _ production has larger sdk data, so absolute sizes there will be higher <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added reusable expand/collapse controls for API reference details, with updated icons, labels, and styling. * Improved code block rendering with class-based syntax highlighting, wrapped-code support, responsive layouts, and lazy language loading. * **Style** * Added theme-aware syntax-token colors, line-number styling, and configurable code-block shadows. * Consolidated expandable reference panel and item styling. * **Tests** * Added coverage for syntax highlighting, code block rendering, language support, token stability, and reference details. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
188 lines
7.2 KiB
TypeScript
188 lines
7.2 KiB
TypeScript
import { readFile } from 'node:fs/promises'
|
|
import { load } from 'cheerio'
|
|
import { type ComponentProps, type PropsWithChildren } from 'react'
|
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
import { createHighlighter, type BundledLanguage, type ThemeRegistration } from 'shiki'
|
|
import { createTwoslasher } from 'twoslash'
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { CodeBlock } from './CodeBlock'
|
|
import { type CodeToken } from './CodeBlock.client'
|
|
import { getTokenClassName } from './CodeBlock.utils'
|
|
|
|
vi.mock('./types/lib.deno.d.ts.include', async () => ({
|
|
default: await readFile(new URL('./types/lib.deno.d.ts.include', import.meta.url), 'utf8'),
|
|
}))
|
|
|
|
// Keep the real token renderer while isolating unrelated UI imports and tooltip portals.
|
|
vi.mock('ui', async () => {
|
|
const { createElement } = await import('react')
|
|
return {
|
|
cn: (...classes: Array<unknown>) => classes.filter(Boolean).join(' '),
|
|
Tooltip: ({ children }: PropsWithChildren) => children,
|
|
TooltipTrigger: ({ children }: PropsWithChildren) => children,
|
|
TooltipContent: () => null,
|
|
Button: ({ variant, ...props }: ComponentProps<'button'> & { variant?: string }) =>
|
|
createElement('button', props),
|
|
copyToClipboard: vi.fn(),
|
|
}
|
|
})
|
|
|
|
function getLines(block: Awaited<ReturnType<typeof CodeBlock>>): Array<Array<CodeToken>> {
|
|
return block.props.children[0].props.children.props.lines
|
|
}
|
|
|
|
const fixtures: Array<{ name: string; lang?: string; code: string }> = [
|
|
{
|
|
name: 'JavaScript',
|
|
lang: 'javascript',
|
|
code: '// A greeting\nconst greeting = "hello"\ngreeting',
|
|
},
|
|
{
|
|
name: 'TypeScript',
|
|
lang: 'typescript',
|
|
code: 'const count: number = 42\nconst values = [count]',
|
|
},
|
|
{ name: 'SQL', lang: 'sql', code: "select 'hello' as greeting, 42 as count;\n-- A comment" },
|
|
{ name: 'shell', lang: 'shell', code: 'echo "hello ${USER}"\n# A comment' },
|
|
{ name: 'JSON', lang: 'json', code: '{\n "greeting": "hello",\n "count": 42\n}' },
|
|
{ name: 'empty code', lang: 'typescript', code: '' },
|
|
{ name: 'plain text', code: 'plain <text> & punctuation\n second line' },
|
|
{ name: 'unsupported language', lang: 'not-a-language', code: 'plain <text> & punctuation' },
|
|
]
|
|
|
|
describe('code block serialization and rendering', () => {
|
|
let highlighter: Awaited<ReturnType<typeof createHighlighter>>
|
|
|
|
beforeAll(async () => {
|
|
// Shiki mutates theme.colors, so use a fresh raw theme for this independent tokenization.
|
|
const theme: ThemeRegistration = JSON.parse(
|
|
await readFile(new URL('./supabase-2.json', import.meta.url), 'utf8')
|
|
)
|
|
highlighter = await createHighlighter({
|
|
themes: [theme],
|
|
langs: ['javascript', 'typescript', 'sql', 'shell', 'json'],
|
|
})
|
|
})
|
|
|
|
afterEach(() => vi.restoreAllMocks())
|
|
afterAll(() => highlighter.dispose())
|
|
|
|
it.each(fixtures)(
|
|
'preserves $name token boundaries using compact namespaced classes',
|
|
async ({ lang, code }) => {
|
|
const block = await CodeBlock({
|
|
contents: code,
|
|
lang,
|
|
skipTypeGeneration: true,
|
|
hideControls: true,
|
|
})
|
|
const lines = getLines(block)
|
|
const { tokens } = highlighter.codeToTokens(code, {
|
|
lang: lang === 'not-a-language' ? undefined : (lang as BundledLanguage | undefined),
|
|
theme: 'Supabase Theme',
|
|
tokenizeTimeLimit: 0,
|
|
tokenizeMaxLineLength: 100_000,
|
|
})
|
|
|
|
expect(lines.map((line) => line.map(([content]) => content))).toEqual(
|
|
tokens.map((line) => line.map(({ content }) => content))
|
|
)
|
|
for (const [lineIndex, line] of tokens.entries()) {
|
|
for (const [tokenIndex, token] of line.entries()) {
|
|
expect(lines[lineIndex][tokenIndex]).toEqual([
|
|
token.content,
|
|
getTokenClassName(token.color, token.fontStyle),
|
|
])
|
|
}
|
|
}
|
|
|
|
const $ = load(renderToStaticMarkup(block))
|
|
expect(
|
|
$('.code-line-number')
|
|
.toArray()
|
|
.map((element) => $(element).text())
|
|
).toEqual(lines.map((_, index) => String(index + 1)))
|
|
expect(
|
|
$('.code-line-content')
|
|
.toArray()
|
|
.map((element) => $(element).text())
|
|
).toEqual(lines.map((line) => line.map(([content]) => content).join('')))
|
|
expect($('.code-content [style]')).toHaveLength(0)
|
|
}
|
|
)
|
|
|
|
it('preserves actual Twoslash annotations and offsets after its source edits', async () => {
|
|
const source = [
|
|
"const prefix = 'Hello'",
|
|
'// ---cut---',
|
|
'/** The name shown in the greeting. */',
|
|
"const username = 'reader'",
|
|
'const message = `${prefix}, ${username}`',
|
|
'message',
|
|
].join('\n')
|
|
const twoslashed = createTwoslasher({ compilerOptions: { ignoreDeprecations: '6.0' } })(source)
|
|
const hovers = twoslashed.nodes.filter((node) => node.type === 'hover')
|
|
expect(hovers.length).toBeGreaterThan(0)
|
|
expect(twoslashed.code).not.toContain('// ---cut---')
|
|
|
|
const block = await CodeBlock({ contents: source, lang: 'typescript', hideControls: true })
|
|
const lines = getLines(block)
|
|
expect(lines.map((line) => line.map(([content]) => content).join('')).join('\n')).toBe(
|
|
twoslashed.code
|
|
)
|
|
|
|
for (const [lineIndex, line] of lines.entries()) {
|
|
let offset = 0
|
|
for (const token of line) {
|
|
const annotations = hovers
|
|
.filter((hover) => hover.line === lineIndex && hover.character === offset)
|
|
.map(({ text, docs, tags }) => ({ text, docs, tags }))
|
|
expect(token[2]).toEqual(annotations.length ? annotations : undefined)
|
|
expect(token).toHaveLength(annotations.length ? 3 : 2)
|
|
offset += token[0].length
|
|
}
|
|
}
|
|
const annotated = lines.flat().filter((token) => token[2])
|
|
expect(annotated.length).toBeGreaterThan(0)
|
|
const $ = load(renderToStaticMarkup(block))
|
|
expect(
|
|
$('.code-content button')
|
|
.toArray()
|
|
.map((element) => $(element).text())
|
|
).toEqual(annotated.map(([content]) => content))
|
|
expect($('.code-content button[tabindex="0"]')).toHaveLength(annotated.length)
|
|
})
|
|
|
|
it('keeps classes stable across repeated renders in a different order', async () => {
|
|
const render = async ({ lang, code }: (typeof fixtures)[number]) =>
|
|
getLines(await CodeBlock({ contents: code, lang, skipTypeGeneration: true }))
|
|
const first = await Promise.all(fixtures.map(render))
|
|
const reversed = await Promise.all([...fixtures].reverse().map(render))
|
|
expect(reversed.reverse()).toEqual(first)
|
|
})
|
|
|
|
it('retains unnumbered layout, source text, hidden controls, and the accessible label', async () => {
|
|
const code = 'echo "hello"\necho "reader"'
|
|
const block = await CodeBlock({
|
|
contents: code,
|
|
lang: 'shell',
|
|
lineNumbers: false,
|
|
hideControls: true,
|
|
})
|
|
const $ = load(renderToStaticMarkup(block))
|
|
expect($('.code-line-number')).toHaveLength(0)
|
|
expect($('.code-content')).toHaveLength(1)
|
|
expect(
|
|
$('.code-content > span')
|
|
.toArray()
|
|
.map((element) => $(element).text())
|
|
.join('\n')
|
|
).toBe(code)
|
|
expect($('button')).toHaveLength(0)
|
|
expect($('.code-scroll').attr('aria-label')).toBe('Shell, 2 lines')
|
|
expect($('.code-scroll').attr('tabindex')).toBe('0')
|
|
expect($('pre > code').hasClass('grid')).toBe(false)
|
|
})
|
|
})
|