@lexibase/rdx-ui 3.0.0 → 3.2.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.
@@ -9,6 +9,8 @@ import {
9
9
  import { getMessages } from '../lib/i18n'
10
10
  import { CodeBlockLine } from './code-block'
11
11
  import { MdxCodeBlockView } from './mdx-code-block-view'
12
+ import { MdxFolderTree } from './mdx-folder-tree'
13
+ import { MdxMermaid } from './mdx-mermaid'
12
14
 
13
15
  function textOf(children: ReactNode): string {
14
16
  return Children.toArray(children)
@@ -47,8 +49,16 @@ export function MdxCodeBlock({
47
49
  lang,
48
50
  children,
49
51
  'data-rdx-code-mode': mode,
52
+ 'data-rdx-code-title': title,
53
+ 'data-rdx-code-line-numbers': numbered,
54
+ 'data-rdx-code-start-line': firstLine,
50
55
  ...props
51
- }: ComponentProps<'pre'> & { 'data-rdx-code-mode'?: 'folder' | 'diff' }) {
56
+ }: ComponentProps<'pre'> & {
57
+ 'data-rdx-code-mode'?: 'folder' | 'diff' | 'mermaid'
58
+ 'data-rdx-code-title'?: string
59
+ 'data-rdx-code-line-numbers'?: string
60
+ 'data-rdx-code-start-line'?: string
61
+ }) {
52
62
  const messages = getMessages(lang)
53
63
  const code = Children.toArray(children).find((child) => isValidElement(child))
54
64
  const codeProps = isValidElement<{
@@ -59,42 +69,85 @@ export function MdxCodeBlock({
59
69
  : undefined
60
70
  const language = codeProps?.className?.match(/(?:^|\s)language-([^\s]+)/)?.[1]
61
71
  const value = textOf(codeProps?.children ?? children)
72
+ if (mode === 'mermaid' || language?.toLowerCase() === 'mermaid') {
73
+ return (
74
+ <MdxMermaid
75
+ value={value}
76
+ title={title}
77
+ lineNumbers={numbered === 'true'}
78
+ startLine={firstLine === undefined ? undefined : Number(firstLine)}
79
+ preProps={props}
80
+ />
81
+ )
82
+ }
62
83
  const label =
63
- mode === 'folder' ? messages.folderTree : (language ?? messages.code)
84
+ title ||
85
+ (mode === 'folder' ? messages.folderTree : (language ?? messages.code))
86
+ const lineNumbers = numbered === 'true'
87
+ const raw = codeProps?.children ?? children
88
+ const nodes = Children.toArray(raw)
89
+ type LineProps = {
90
+ children?: ReactNode
91
+ className?: string
92
+ 'data-rdx-line-variant'?: string
93
+ }
94
+ const lines = nodes.filter(
95
+ (node) =>
96
+ isValidElement<LineProps>(node) &&
97
+ (node.props['data-rdx-line-variant'] ||
98
+ node.props.className?.split(/\s+/).includes('line'))
99
+ )
100
+ const lastLine = lines.at(-1)
64
101
  const content =
65
- mode === 'diff'
66
- ? Children.map(codeProps?.children, (node) => {
67
- if (
68
- !isValidElement<{
69
- children?: ReactNode
70
- 'data-rdx-line-variant'?: string
71
- }>(node)
72
- )
73
- return node
74
- const variant = node.props['data-rdx-line-variant']
75
- if (!variant) return node
76
- return (
77
- <CodeBlockLine
78
- variant={
79
- variant === 'added' || variant === 'removed'
80
- ? variant
81
- : variant === 'hunk'
82
- ? 'highlighted'
83
- : 'default'
84
- }
85
- >
86
- {variant === 'added' || variant === 'removed'
87
- ? withoutMarker(node.props.children)
88
- : node.props.children}
89
- </CodeBlockLine>
90
- )
91
- })
92
- : (codeProps?.children ?? children)
102
+ mode === 'folder' ? (
103
+ <MdxFolderTree value={value} />
104
+ ) : (mode === 'diff' || lineNumbers) && typeof raw !== 'string' ? (
105
+ nodes.map((node) => {
106
+ if (lineNumbers && node === '\n') return null
107
+ if (!isValidElement<LineProps>(node)) return node
108
+ const variant = node.props['data-rdx-line-variant']
109
+ if (
110
+ !variant &&
111
+ !(lineNumbers && node.props.className?.split(/\s+/).includes('line'))
112
+ )
113
+ return node
114
+ // A final newline terminates the last source line; intentional blank
115
+ // lines before it still receive the official component's own number.
116
+ if (
117
+ lineNumbers &&
118
+ lines.length > 1 &&
119
+ node === lastLine &&
120
+ value.endsWith('\n') &&
121
+ textOf(node.props.children) === ''
122
+ )
123
+ return null
124
+ return (
125
+ <CodeBlockLine
126
+ key={node.key}
127
+ variant={
128
+ variant === 'added' || variant === 'removed'
129
+ ? variant
130
+ : variant === 'hunk'
131
+ ? 'highlighted'
132
+ : 'default'
133
+ }
134
+ >
135
+ {variant === 'added' || variant === 'removed'
136
+ ? withoutMarker(node.props.children)
137
+ : node.props.children}
138
+ </CodeBlockLine>
139
+ )
140
+ })
141
+ ) : (
142
+ raw
143
+ )
93
144
  return (
94
145
  <MdxCodeBlockView
95
146
  label={label}
96
147
  mode={mode}
97
148
  value={value}
149
+ lineNumbers={lineNumbers}
150
+ startLine={firstLine === undefined ? undefined : Number(firstLine)}
98
151
  preProps={{
99
152
  ...props,
100
153
  className: [props.className, language && `language-${language}`]
@@ -29,6 +29,7 @@ import {
29
29
  } from './code-block'
30
30
  import { MdxCodeBlock } from './mdx-code-block'
31
31
  import { Kbd, KbdGroup } from './kbd'
32
+ import { MdxKeyboardShortcut } from './mdx-keyboard-shortcut'
32
33
  import {
33
34
  Table,
34
35
  TableBody,
@@ -105,10 +106,7 @@ export const mdxComponents = {
105
106
  />
106
107
  ),
107
108
  a: ({ className, children, ...props }: ComponentProps<'a'>) => (
108
- <a
109
- className={cn('underline text-chart-1 text-blue-500', className)}
110
- {...props}
111
- >
109
+ <a className={cn('underline text-rdx-highlighting', className)} {...props}>
112
110
  {linkedImages(children)}
113
111
  </a>
114
112
  ),
@@ -157,6 +155,7 @@ export const mdxComponents = {
157
155
  CodeBlockTitle,
158
156
  Kbd,
159
157
  KbdGroup,
158
+ KeyboardShortcut: MdxKeyboardShortcut,
160
159
  Table,
161
160
  TableHeader,
162
161
  TableBody,
@@ -190,6 +189,9 @@ export const mdxComponents = {
190
189
  export function getMdxComponents(lang?: string) {
191
190
  return {
192
191
  ...mdxComponents,
192
+ KeyboardShortcut: (props: { children?: ReactNode }) => (
193
+ <MdxKeyboardShortcut {...props} lang={lang} />
194
+ ),
193
195
  pre: (props: ComponentProps<'pre'>) => (
194
196
  <MdxCodeBlock {...props} lang={lang} />
195
197
  ),
@@ -0,0 +1,58 @@
1
+ import {
2
+ File,
3
+ FileCode2,
4
+ FileImage,
5
+ FileJson,
6
+ FileText,
7
+ Folder,
8
+ } from 'lucide-react'
9
+
10
+ import { CodeBlockLine } from './code-block'
11
+
12
+ function entryIcon(name: string) {
13
+ if (name.endsWith('/')) return { Icon: Folder, kind: 'folder' }
14
+ const extension = name.includes('.')
15
+ ? name.split('.').at(-1)?.toLowerCase()
16
+ : undefined
17
+ if (extension === 'json' || extension === 'jsonc')
18
+ return { Icon: FileJson, kind: 'json' }
19
+ if (extension && /^(?:png|jpe?g|gif|webp|svg|avif|ico|bmp)$/.test(extension))
20
+ return { Icon: FileImage, kind: 'image' }
21
+ if (extension && /^(?:mdx?|txt|log|rst)$/.test(extension))
22
+ return { Icon: FileText, kind: 'text' }
23
+ if (
24
+ extension &&
25
+ /^(?:[cm]?[jt]sx?|html|css|scss|vue|svelte|py|rb|go|rs|java|c|cpp|h|cs|php|sql|sh|bash|ya?ml|toml)$/.test(
26
+ extension
27
+ )
28
+ )
29
+ return { Icon: FileCode2, kind: 'code' }
30
+ return { Icon: File, kind: 'file' }
31
+ }
32
+
33
+ /** Decorate generated text with icons inside the official shadcn line component. */
34
+ export function MdxFolderTree({ value }: { value: string }) {
35
+ const trailingNewline = value.endsWith('\n')
36
+ const lines = value.replace(/\n$/, '').split('\n')
37
+ return lines.map((line, index) => {
38
+ const match = line.match(/^([ │\t]*(?:├──|└──)\s*)(.*)$/)
39
+ const prefix = match?.[1] ?? line.match(/^[ │\t]*/)?.[0] ?? ''
40
+ const name = match?.[2] ?? line.slice(prefix.length)
41
+ const { Icon, kind } = entryIcon(name)
42
+ return (
43
+ <CodeBlockLine key={index} data-rdx-folder-kind={name ? kind : undefined}>
44
+ {prefix && <span aria-hidden="true">{prefix}</span>}
45
+ {name && (
46
+ <>
47
+ <Icon
48
+ aria-hidden="true"
49
+ className="mr-1.5 inline-block size-3.5 align-[-2px] text-muted-foreground"
50
+ />
51
+ {name}
52
+ </>
53
+ )}
54
+ {index < lines.length - 1 || trailingNewline ? '\n' : null}
55
+ </CodeBlockLine>
56
+ )
57
+ })
58
+ }
@@ -0,0 +1,74 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { getMessages, type InterfaceMessages } from '../lib/i18n'
4
+ import { Kbd, KbdGroup } from './kbd'
5
+
6
+ const keyNames: Record<string, keyof InterfaceMessages> = {
7
+ ctrl: 'keyboardControl',
8
+ control: 'keyboardControl',
9
+ '⌃': 'keyboardControl',
10
+ shift: 'keyboardShift',
11
+ '⇧': 'keyboardShift',
12
+ alt: 'keyboardAlt',
13
+ cmd: 'keyboardCommand',
14
+ command: 'keyboardCommand',
15
+ '⌘': 'keyboardCommand',
16
+ option: 'keyboardOption',
17
+ '⌥': 'keyboardOption',
18
+ esc: 'keyboardEscape',
19
+ escape: 'keyboardEscape',
20
+ '⎋': 'keyboardEscape',
21
+ enter: 'keyboardEnter',
22
+ return: 'keyboardEnter',
23
+ '↵': 'keyboardEnter',
24
+ '⏎': 'keyboardEnter',
25
+ space: 'keyboardSpace',
26
+ spacebar: 'keyboardSpace',
27
+ '+': 'keyboardPlus',
28
+ arrowup: 'keyboardArrowUp',
29
+ '↑': 'keyboardArrowUp',
30
+ arrowdown: 'keyboardArrowDown',
31
+ '↓': 'keyboardArrowDown',
32
+ arrowleft: 'keyboardArrowLeft',
33
+ '←': 'keyboardArrowLeft',
34
+ arrowright: 'keyboardArrowRight',
35
+ '→': 'keyboardArrowRight',
36
+ }
37
+
38
+ /** Markdown adapter: preserve shadcn visuals, expose one readable text alternative. */
39
+ export function MdxKeyboardShortcut({
40
+ children,
41
+ lang,
42
+ }: {
43
+ children?: ReactNode
44
+ lang?: string
45
+ }) {
46
+ if (typeof children !== 'string') return <Kbd>{children}</Kbd>
47
+ const keys = children
48
+ .trim()
49
+ .split(/\s+\+\s+/)
50
+ .map((key) => key.trim())
51
+ if (keys.length < 2 || keys.some((key) => !key)) return <Kbd>{children}</Kbd>
52
+ const messages = getMessages(lang)
53
+ const description = keys
54
+ .map((key) => {
55
+ const name = Object.hasOwn(keyNames, key.toLowerCase())
56
+ ? keyNames[key.toLowerCase()]
57
+ : undefined
58
+ return name ? messages[name] : key
59
+ })
60
+ .join(messages.keyboardJoiner)
61
+ return (
62
+ <span data-rdx-kbd-value={children} className="inline-flex align-baseline">
63
+ <span className="sr-only select-none">{description}</span>
64
+ <KbdGroup aria-hidden="true">
65
+ {keys.map((key, index) => (
66
+ <span key={index} className="inline-flex items-center gap-1">
67
+ {index > 0 && <span>+</span>}
68
+ <Kbd>{key}</Kbd>
69
+ </span>
70
+ ))}
71
+ </KbdGroup>
72
+ </span>
73
+ )
74
+ }
@@ -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
+ }