@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.
- package/THIRD_PARTY_NOTICES.md +8 -2
- package/package.json +8 -3
- package/src/components/button.tsx +2 -0
- package/src/components/code-block.tsx +274 -46
- package/src/components/copy-code-button.tsx +1 -79
- package/src/components/dialog.tsx +123 -36
- package/src/components/docs-page-transition.tsx +178 -0
- package/src/components/docs-search-dialog.tsx +6 -1
- package/src/components/docs-sidebar.tsx +103 -30
- package/src/components/docs-toc.tsx +6 -6
- package/src/components/mdx-code-block-view.tsx +203 -0
- package/src/components/mdx-code-block.tsx +161 -0
- package/src/components/mdx-components.tsx +47 -16
- package/src/components/mdx-folder-tree.tsx +58 -0
- package/src/components/mdx-image.tsx +73 -0
- package/src/components/mdx-mermaid.tsx +252 -0
- package/src/components/sheet.tsx +146 -0
- package/src/components/tabs.tsx +12 -3
- package/src/hooks/use-highlighted-code.ts +75 -0
- package/src/lib/highlighter.tsx +124 -0
- package/src/lib/i18n.ts +32 -0
- package/src/lib/mermaid.ts +114 -0
- package/src/styles/globals.css +91 -14
- package/src/styles/shadcn.css +650 -0
|
@@ -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
|
+
}
|
package/src/components/tabs.tsx
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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
|
+
}
|