@lexibase/rdx-ui 2.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ 'use client'
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ComponentProps,
9
+ } from 'react'
10
+ import { useTheme } from 'next-themes'
11
+ import {
12
+ CodeXml,
13
+ Eye,
14
+ Maximize2,
15
+ RotateCw,
16
+ Workflow,
17
+ WrapText,
18
+ } from 'lucide-react'
19
+
20
+ import { renderMermaid } from '../lib/mermaid'
21
+ import { useMessages } from '../providers/locale-provider'
22
+ import { Button } from './button'
23
+ import {
24
+ CodeBlock,
25
+ CodeBlockActions,
26
+ CodeBlockContent,
27
+ CodeBlockCopyButton,
28
+ CodeBlockHeader,
29
+ CodeBlockTitle,
30
+ } from './code-block'
31
+ import {
32
+ Dialog,
33
+ DialogContent,
34
+ DialogDescription,
35
+ DialogTitle,
36
+ DialogTrigger,
37
+ } from './dialog'
38
+
39
+ /** A Mermaid fence adapts the existing shadcn Code Block and Dialog. */
40
+ export function MdxMermaid({
41
+ value,
42
+ title,
43
+ lineNumbers,
44
+ startLine,
45
+ preProps,
46
+ }: {
47
+ value: string
48
+ title?: string
49
+ lineNumbers?: boolean
50
+ startLine?: number
51
+ preProps: ComponentProps<'pre'>
52
+ }) {
53
+ const messages = useMessages()
54
+ const { resolvedTheme } = useTheme()
55
+ const block = useRef<HTMLDivElement>(null)
56
+ const [visible, setVisible] = useState(false)
57
+ const [sourceView, setSourceView] = useState(false)
58
+ const [wrap, setWrap] = useState(false)
59
+ const [attempt, setAttempt] = useState(0)
60
+ const [result, setResult] = useState<{
61
+ source: string
62
+ theme: string
63
+ svg?: string
64
+ error?: boolean
65
+ }>()
66
+ const current =
67
+ result?.source === value && result.theme === resolvedTheme
68
+ ? result
69
+ : undefined
70
+ const svg = current?.svg
71
+ const error = current?.error
72
+ const image = svg
73
+ ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
74
+ : undefined
75
+ const description = useMemo(() => {
76
+ if (!svg) return title || messages.diagram
77
+ const document = new DOMParser().parseFromString(svg, 'image/svg+xml')
78
+ return [
79
+ title || document.querySelector('title')?.textContent || messages.diagram,
80
+ document.querySelector('desc')?.textContent,
81
+ ]
82
+ .filter(Boolean)
83
+ .join('. ')
84
+ }, [svg, title, messages.diagram])
85
+ const label = title || 'Mermaid'
86
+ const showSource = sourceView || !svg
87
+
88
+ useEffect(() => {
89
+ const element = block.current
90
+ if (!element) return
91
+ const observer = new IntersectionObserver(
92
+ (entries) => {
93
+ if (entries.some((entry) => entry.isIntersecting)) {
94
+ setVisible(true)
95
+ observer.disconnect()
96
+ }
97
+ },
98
+ { rootMargin: '200px' }
99
+ )
100
+ observer.observe(element)
101
+ return () => observer.disconnect()
102
+ }, [])
103
+
104
+ useEffect(() => {
105
+ if (!visible || !resolvedTheme) return
106
+ let active = true
107
+ const theme = resolvedTheme
108
+ void renderMermaid(value, theme === 'dark', () => active).then(
109
+ (svg) => {
110
+ if (active && svg) setResult({ source: value, theme, svg })
111
+ },
112
+ () => {
113
+ if (active) setResult({ source: value, theme, error: true })
114
+ }
115
+ )
116
+ return () => {
117
+ active = false
118
+ }
119
+ }, [value, resolvedTheme, visible, attempt])
120
+
121
+ return (
122
+ <CodeBlock
123
+ ref={block}
124
+ size="sm"
125
+ data-rdx-code-block
126
+ data-rdx-code-mode="mermaid"
127
+ data-rdx-code-value={value}
128
+ className="my-4 max-w-full"
129
+ >
130
+ <CodeBlockHeader>
131
+ <CodeBlockTitle title={label}>
132
+ <Workflow aria-hidden="true" /> {label}
133
+ </CodeBlockTitle>
134
+ <CodeBlockActions>
135
+ {svg && (
136
+ <Button
137
+ variant="ghost"
138
+ size="icon-xs"
139
+ aria-label={
140
+ sourceView ? messages.showDiagram : messages.showDiagramSource
141
+ }
142
+ title={
143
+ sourceView ? messages.showDiagram : messages.showDiagramSource
144
+ }
145
+ aria-pressed={sourceView}
146
+ onClick={() => setSourceView(!sourceView)}
147
+ >
148
+ {sourceView ? (
149
+ <Eye aria-hidden="true" />
150
+ ) : (
151
+ <CodeXml aria-hidden="true" />
152
+ )}
153
+ </Button>
154
+ )}
155
+ {image && (
156
+ <Dialog>
157
+ <DialogTrigger
158
+ render={<Button variant="ghost" size="icon-xs" />}
159
+ aria-label={messages.enlargeDiagram}
160
+ title={messages.enlargeDiagram}
161
+ >
162
+ <Maximize2 aria-hidden="true" />
163
+ </DialogTrigger>
164
+ <DialogContent
165
+ data-rdx-mermaid-dialog
166
+ className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-6xl flex-col gap-2 sm:max-w-6xl"
167
+ >
168
+ <DialogTitle className="pr-8">
169
+ {title || messages.diagram}
170
+ </DialogTitle>
171
+ <DialogDescription className="sr-only">
172
+ {messages.imagePreviewDescription}
173
+ </DialogDescription>
174
+ <div
175
+ role="region"
176
+ aria-label={messages.diagram}
177
+ tabIndex={0}
178
+ className="min-h-0 overflow-auto rounded-md p-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
179
+ >
180
+ <img
181
+ src={image}
182
+ alt={description}
183
+ className="mx-auto h-auto max-w-none"
184
+ />
185
+ </div>
186
+ </DialogContent>
187
+ </Dialog>
188
+ )}
189
+ {error && (
190
+ <Button
191
+ variant="ghost"
192
+ size="icon-xs"
193
+ aria-label={messages.tryAgain}
194
+ title={messages.tryAgain}
195
+ onClick={() => setAttempt(attempt + 1)}
196
+ >
197
+ <RotateCw aria-hidden="true" />
198
+ </Button>
199
+ )}
200
+ {showSource && (
201
+ <Button
202
+ variant="ghost"
203
+ size="icon-xs"
204
+ className="md:hidden"
205
+ aria-label={messages.wrapCode}
206
+ title={messages.wrapCode}
207
+ aria-pressed={wrap}
208
+ onClick={() => setWrap(!wrap)}
209
+ >
210
+ <WrapText aria-hidden="true" />
211
+ </Button>
212
+ )}
213
+ <CodeBlockCopyButton value={value} />
214
+ </CodeBlockActions>
215
+ </CodeBlockHeader>
216
+ {error && (
217
+ <p role="status" className="px-3 pt-2 text-xs text-muted-foreground">
218
+ {messages.diagramError}
219
+ </p>
220
+ )}
221
+ {!current && visible && (
222
+ <p role="status" className="px-3 pt-2 text-xs text-muted-foreground">
223
+ {messages.diagramLoading}
224
+ </p>
225
+ )}
226
+ {showSource ? (
227
+ <CodeBlockContent
228
+ {...preProps}
229
+ wrap={wrap}
230
+ lineNumbers={lineNumbers}
231
+ startLine={startLine}
232
+ >
233
+ {value}
234
+ </CodeBlockContent>
235
+ ) : (
236
+ <div
237
+ role="region"
238
+ aria-label={title || messages.diagram}
239
+ tabIndex={0}
240
+ data-rdx-mermaid-preview
241
+ className="min-w-0 overflow-auto p-4 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
242
+ >
243
+ <img
244
+ src={image}
245
+ alt={description}
246
+ className="mx-auto h-auto max-w-full"
247
+ />
248
+ </div>
249
+ )}
250
+ </CodeBlock>
251
+ )
252
+ }
@@ -0,0 +1,146 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'
5
+ import { cn, mergeStateClassName } from '../lib/utils'
6
+ import { useMessages } from '../providers/locale-provider'
7
+
8
+ import { Button } from './button'
9
+ import { XIcon } from 'lucide-react'
10
+
11
+ function Sheet({ ...props }: SheetPrimitive.Root.Props) {
12
+ return <SheetPrimitive.Root data-slot="sheet" {...props} />
13
+ }
14
+
15
+ function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
16
+ return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
17
+ }
18
+
19
+ function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
20
+ return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
21
+ }
22
+
23
+ function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
24
+ return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
25
+ }
26
+
27
+ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
28
+ return (
29
+ <SheetPrimitive.Backdrop
30
+ data-slot="sheet-overlay"
31
+ className={mergeStateClassName(
32
+ 'fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs motion-reduce:transition-none',
33
+ className
34
+ )}
35
+ {...props}
36
+ />
37
+ )
38
+ }
39
+
40
+ function SheetContent({
41
+ className,
42
+ children,
43
+ side = 'right',
44
+ showCloseButton = true,
45
+ closeLabel,
46
+ portalProps,
47
+ ...props
48
+ }: SheetPrimitive.Popup.Props & {
49
+ side?: 'top' | 'right' | 'bottom' | 'left'
50
+ showCloseButton?: boolean
51
+ closeLabel?: string
52
+ portalProps?: SheetPrimitive.Portal.Props
53
+ }) {
54
+ const messages = useMessages()
55
+ return (
56
+ <SheetPortal {...portalProps}>
57
+ <SheetOverlay />
58
+ <SheetPrimitive.Popup
59
+ data-slot="sheet-content"
60
+ data-side={side}
61
+ className={mergeStateClassName(
62
+ 'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm motion-reduce:transition-none',
63
+ className
64
+ )}
65
+ {...props}
66
+ >
67
+ {children}
68
+ {showCloseButton && (
69
+ <SheetPrimitive.Close
70
+ data-slot="sheet-close"
71
+ render={
72
+ <Button
73
+ variant="ghost"
74
+ className="absolute top-3 right-3"
75
+ size="icon-sm"
76
+ />
77
+ }
78
+ >
79
+ <XIcon aria-hidden="true" />
80
+ <span className="sr-only">{closeLabel ?? messages.close}</span>
81
+ </SheetPrimitive.Close>
82
+ )}
83
+ </SheetPrimitive.Popup>
84
+ </SheetPortal>
85
+ )
86
+ }
87
+
88
+ function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
89
+ return (
90
+ <div
91
+ data-slot="sheet-header"
92
+ className={cn('flex flex-col gap-0.5 p-4', className)}
93
+ {...props}
94
+ />
95
+ )
96
+ }
97
+
98
+ function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
99
+ return (
100
+ <div
101
+ data-slot="sheet-footer"
102
+ className={cn('mt-auto flex flex-col gap-2 p-4', className)}
103
+ {...props}
104
+ />
105
+ )
106
+ }
107
+
108
+ function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
109
+ return (
110
+ <SheetPrimitive.Title
111
+ data-slot="sheet-title"
112
+ className={mergeStateClassName(
113
+ 'cn-font-heading text-base font-medium text-foreground',
114
+ className
115
+ )}
116
+ {...props}
117
+ />
118
+ )
119
+ }
120
+
121
+ function SheetDescription({
122
+ className,
123
+ ...props
124
+ }: SheetPrimitive.Description.Props) {
125
+ return (
126
+ <SheetPrimitive.Description
127
+ data-slot="sheet-description"
128
+ className={mergeStateClassName(
129
+ 'text-sm text-muted-foreground',
130
+ className
131
+ )}
132
+ {...props}
133
+ />
134
+ )
135
+ }
136
+
137
+ export {
138
+ Sheet,
139
+ SheetTrigger,
140
+ SheetClose,
141
+ SheetContent,
142
+ SheetHeader,
143
+ SheetFooter,
144
+ SheetTitle,
145
+ SheetDescription,
146
+ }
@@ -25,8 +25,13 @@ function panelText(node: Node): string {
25
25
  )
26
26
  )
27
27
  return ''
28
- if (node.matches('[data-rdx-code-block]')) {
29
- return `${node.querySelector('pre')?.textContent ?? ''}\n`
28
+ if (node.matches('[data-rdx-code-block], [data-slot="code-block"]')) {
29
+ const pre = node.querySelector('pre')
30
+ const lines = pre?.querySelectorAll('[data-slot="code-block-line-content"]')
31
+ const text = lines?.length
32
+ ? Array.from(lines, (line) => line.textContent ?? '').join('\n')
33
+ : pre?.textContent
34
+ return `${node.dataset.rdxCodeValue ?? text ?? ''}\n`
30
35
  }
31
36
  if (node.tagName === 'PRE') return `${node.textContent ?? ''}\n`
32
37
  if (node.tagName === 'BR') return '\n'
@@ -139,6 +144,7 @@ function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
139
144
  }
140
145
 
141
146
  function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
147
+ const messages = useMessages()
142
148
  return (
143
149
  <div
144
150
  data-slot="tabs-header"
@@ -146,6 +152,9 @@ function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
146
152
  >
147
153
  <TabsPrimitive.List
148
154
  data-slot="tabs-list"
155
+ aria-label={
156
+ props['aria-labelledby'] ? undefined : messages.documentationTabs
157
+ }
149
158
  className={mergeStateClassName(
150
159
  'flex min-w-0 flex-1 gap-0.5 overflow-x-auto p-1.5 data-[orientation=vertical]:flex-col',
151
160
  className
@@ -162,7 +171,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
162
171
  <TabsPrimitive.Tab
163
172
  data-slot="tabs-trigger"
164
173
  className={mergeStateClassName(
165
- 'relative shrink-0 rounded-md border border-transparent px-2.5 py-1 text-sm font-medium whitespace-nowrap text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-active:border-border data-active:bg-background data-active:text-foreground data-active:shadow-xs data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-transparent data-disabled:hover:text-muted-foreground motion-reduce:transition-none',
174
+ 'relative shrink-0 rounded-md border border-transparent px-2.5 py-1 text-sm font-medium whitespace-nowrap text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-active:border-border data-active:bg-background data-active:text-rdx-selected data-active:shadow-xs data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-transparent data-disabled:hover:text-muted-foreground motion-reduce:transition-none',
166
175
  className
167
176
  )}
168
177
  {...props}
@@ -0,0 +1,75 @@
1
+ import * as React from 'react'
2
+
3
+ import { loadHighlighter } from '../lib/highlighter'
4
+
5
+ export function useHighlightedCode(value: string, language: string) {
6
+ // A trailing newline would render as an extra empty line.
7
+ const code = value.endsWith('\n') ? value.slice(0, -1) : value
8
+ const [loaded, setLoaded] = React.useState<{
9
+ language: string
10
+ highlighter: Awaited<ReturnType<typeof loadHighlighter>>
11
+ } | null>(null)
12
+ const highlighter = loaded?.language === language ? loaded.highlighter : null
13
+
14
+ React.useEffect(() => {
15
+ let cancelled = false
16
+
17
+ loadHighlighter(language)
18
+ .then((next) => {
19
+ if (!cancelled) {
20
+ setLoaded({ language, highlighter: next })
21
+ }
22
+ })
23
+ .catch(() => {})
24
+
25
+ return () => {
26
+ cancelled = true
27
+ }
28
+ }, [language])
29
+
30
+ // The complete lines, highlighted. While the code streams, only the new
31
+ // lines are tokenized, starting from the grammar state of the last one.
32
+ const [done, setDone] = React.useState<{
33
+ highlighter: typeof highlighter
34
+ text: string
35
+ lines: React.ReactNode[]
36
+ state?: Parameters<NonNullable<typeof highlighter>['highlight']>[1]
37
+ }>({ highlighter: null, text: '', lines: [] })
38
+
39
+ let next = done
40
+
41
+ if (next.highlighter !== highlighter || !code.startsWith(next.text)) {
42
+ next = { highlighter, text: '', lines: [] }
43
+ }
44
+
45
+ const end = code.lastIndexOf('\n') + 1
46
+
47
+ if (highlighter && end > next.text.length) {
48
+ const result = highlighter.highlight(
49
+ code.slice(next.text.length, end - 1),
50
+ next.state
51
+ )
52
+
53
+ next = {
54
+ highlighter,
55
+ text: code.slice(0, end),
56
+ lines: [...next.lines, ...result.lines],
57
+ state: result.state,
58
+ }
59
+ }
60
+
61
+ if (next !== done) {
62
+ setDone(next)
63
+ }
64
+
65
+ // The last line is still being written, so it is tokenized on every render.
66
+ const tail = code.slice(end)
67
+
68
+ return React.useMemo(() => {
69
+ if (!highlighter) {
70
+ return code.split('\n')
71
+ }
72
+
73
+ return [...next.lines, highlighter.highlight(tail, next.state).lines[0]]
74
+ }, [code, highlighter, next, tail])
75
+ }
@@ -0,0 +1,124 @@
1
+ import * as React from 'react'
2
+ import {
3
+ createHighlighterCore,
4
+ type GrammarState,
5
+ type HighlighterCore,
6
+ } from 'shiki/core'
7
+ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
8
+
9
+ // Add the languages you need. Each one loads the first time it is used.
10
+ const languages = {
11
+ bash: () => import('shiki/langs/shellscript.mjs'),
12
+ css: () => import('shiki/langs/css.mjs'),
13
+ diff: () => import('shiki/langs/diff.mjs'),
14
+ html: () => import('shiki/langs/html.mjs'),
15
+ javascript: () => import('shiki/langs/javascript.mjs'),
16
+ json: () => import('shiki/langs/json.mjs'),
17
+ jsx: () => import('shiki/langs/jsx.mjs'),
18
+ markdown: () => import('shiki/langs/markdown.mjs'),
19
+ python: () => import('shiki/langs/python.mjs'),
20
+ sql: () => import('shiki/langs/sql.mjs'),
21
+ tsx: () => import('shiki/langs/tsx.mjs'),
22
+ typescript: () => import('shiki/langs/typescript.mjs'),
23
+ yaml: () => import('shiki/langs/yaml.mjs'),
24
+ }
25
+
26
+ const aliases: Record<string, keyof typeof languages> = {
27
+ js: 'javascript',
28
+ md: 'markdown',
29
+ py: 'python',
30
+ sh: 'bash',
31
+ shell: 'bash',
32
+ shellscript: 'bash',
33
+ ts: 'typescript',
34
+ yml: 'yaml',
35
+ zsh: 'bash',
36
+ }
37
+
38
+ let highlighter: Promise<HighlighterCore> | undefined
39
+
40
+ function getHighlighter() {
41
+ highlighter ??= createHighlighterCore({
42
+ themes: [
43
+ import('shiki/themes/github-light.mjs'),
44
+ import('shiki/themes/github-dark.mjs'),
45
+ ],
46
+ langs: [],
47
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
48
+ })
49
+
50
+ return highlighter
51
+ }
52
+
53
+ function getLanguage(language: string) {
54
+ const name = language.toLowerCase()
55
+
56
+ if (name === 'ansi') {
57
+ return name
58
+ }
59
+
60
+ if (name in languages) {
61
+ return name as keyof typeof languages
62
+ }
63
+
64
+ return aliases[name] ?? null
65
+ }
66
+
67
+ // Loads a language and returns a highlighter for it, or null for a language
68
+ // that is not listed.
69
+ export async function loadHighlighter(language: string) {
70
+ const lang = getLanguage(language)
71
+
72
+ if (!lang) {
73
+ return null
74
+ }
75
+
76
+ const shiki = await getHighlighter()
77
+
78
+ if (lang !== 'ansi' && !shiki.getLoadedLanguages().includes(lang)) {
79
+ await shiki.loadLanguage(languages[lang])
80
+ }
81
+
82
+ return {
83
+ // Returns one React node per line, and the grammar state after the last
84
+ // line. Pass that state back to continue where the code left off.
85
+ highlight(code: string, state?: GrammarState) {
86
+ const tokenize = (text: string) =>
87
+ shiki.codeToTokens(text, {
88
+ lang,
89
+ themes: { light: 'github-light', dark: 'github-dark' },
90
+ defaultColor: false,
91
+ grammarState: state,
92
+ }).tokens
93
+
94
+ // ANSI has no grammar state, so each line is tokenized on its own and
95
+ // gets the same colors whether it arrives in one piece or streams.
96
+ const tokens =
97
+ lang === 'ansi'
98
+ ? code.split('\n').flatMap((line) => tokenize(line))
99
+ : tokenize(code)
100
+
101
+ return {
102
+ lines: tokens.map((line) =>
103
+ line.map((token, index) => (
104
+ <span
105
+ key={index}
106
+ style={token.htmlStyle as React.CSSProperties}
107
+ className="text-(--shiki-light) dark:text-(--shiki-dark)"
108
+ >
109
+ {token.content}
110
+ </span>
111
+ ))
112
+ ),
113
+ state: shiki.getLastGrammarState(tokens),
114
+ }
115
+ },
116
+ }
117
+ }
118
+
119
+ // Returns one React node per line, or null for a language that is not listed.
120
+ export async function highlightCode(code: string, language: string) {
121
+ const highlighter = await loadHighlighter(language)
122
+
123
+ return highlighter ? highlighter.highlight(code).lines : null
124
+ }