-
Notifications
You must be signed in to change notification settings - Fork 35
Expand file tree
/
Copy pathMDCRenderer.tsx
More file actions
293 lines (258 loc) · 7.24 KB
/
Copy pathMDCRenderer.tsx
File metadata and controls
293 lines (258 loc) · 7.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
import type { MinimarkElement, MinimarkNode, MinimarkTree } from 'minimark'
import React, { lazy, Suspense, useMemo } from 'react'
import { standardProseComponents } from '.'
import { camelCase, pascalCase } from 'scule'
import { findLastTextNodeAndAppendNode, getCaret } from '../../utils/caret'
/**
* Default HTML tag mappings for MDC elements
*/
const defaultTagMap: Record<string, string> = {
p: 'p',
h1: 'h1',
h2: 'h2',
h3: 'h3',
h4: 'h4',
h5: 'h5',
h6: 'h6',
ul: 'ul',
ol: 'ol',
li: 'li',
a: 'a',
strong: 'strong',
em: 'em',
code: 'code',
pre: 'pre',
blockquote: 'blockquote',
hr: 'hr',
br: 'br',
img: 'img',
table: 'table',
thead: 'thead',
tbody: 'tbody',
tr: 'tr',
th: 'th',
td: 'td',
del: 'del',
div: 'div',
span: 'span',
}
/**
* Helper to get tag from a MinimarkNode
*/
function getTag(node: MinimarkNode): string | null {
if (Array.isArray(node) && node.length >= 1) {
return node[0] as string
}
return null
}
function cssStringToObject(cssString: string): Record<string, string> {
return cssString
.split(';')
.filter(Boolean)
.reduce((acc, rule) => {
const [prop, value] = rule.split(':')
if (!prop || !value) return acc
let camelProp = prop.trim()
if (!prop.startsWith('--')) {
camelProp = camelCase(camelProp)
}
acc[camelProp] = value.trim()
return acc
}, {} as Record<string, string>)
}
/**
* Helper to get props from a MinimarkNode
*/
function getProps(node: MinimarkNode): Record<string, any> {
if (Array.isArray(node) && node.length >= 2) {
return (node[1] as Record<string, any>) || {}
}
return {}
}
function parsePropValue(value: string): any {
if (value === 'true') return true
if (value === 'false') return false
if (value === 'null') return null
try {
return JSON.parse(value)
}
catch {
// noop
}
return value
}
/**
* Helper to get children from a MinimarkNode
*/
function getChildren(node: MinimarkNode): MinimarkNode[] {
if (Array.isArray(node) && node.length > 2) {
return node.slice(2) as MinimarkNode[]
}
return []
}
// Cache for dynamically resolved components
const asyncComponentCache = new Map<string, React.LazyExoticComponent<any>>()
/**
* Render a single MDC node to React element
*/
function renderNode(
node: MinimarkNode,
components: Record<string, any> = {},
key?: string | number,
componentsManifest?: (name: string) => Promise<{ default: React.ComponentType<any> }>,
): React.ReactNode {
// Handle text nodes (strings)
if (typeof node === 'string') {
return node
}
// Handle element nodes (arrays)
if (Array.isArray(node)) {
const tag = getTag(node)
if (!tag) return null
const nodeProps = getProps(node)
const children = getChildren(node)
// Check if there's a custom component for this tag (exact match or PascalCase)
let customComponent = components[tag] || components[pascalCase(tag)]
// If not in components map and manifest is provided, try dynamic resolution
if (!customComponent && componentsManifest && !defaultTagMap[tag]) {
const cacheKey = tag
if (!asyncComponentCache.has(cacheKey)) {
asyncComponentCache.set(
cacheKey,
lazy(() => componentsManifest(tag)),
)
}
customComponent = asyncComponentCache.get(cacheKey)
}
const Component = customComponent || defaultTagMap[tag] || tag
// Prepare props
const props: Record<string, any> = { ...nodeProps }
if (typeof Component !== 'string') {
props.__node = node
}
// Parse special prop values (props starting with :)
for (const [propKey, value] of Object.entries(nodeProps)) {
if (propKey === 'style') {
props.style = cssStringToObject(value)
}
else if (propKey === 'tabindex') {
props.tabIndex = value
Reflect.deleteProperty(props, propKey)
}
if (propKey === 'class') {
props.className = value
Reflect.deleteProperty(props, propKey)
}
else {
if (propKey.startsWith(':')) {
props[propKey.substring(1)] = parsePropValue(value)
Reflect.deleteProperty(props, propKey)
}
}
}
// Add key if provided
if (key !== undefined) {
props.key = key
}
// Handle self-closing tags
if (['hr', 'br', 'img'].includes(tag)) {
return React.createElement(Component, props)
}
// Render children
const renderedChildren = children
.map((child, index) => renderNode(child, components, index, componentsManifest))
.filter(child => child !== null)
// Wrap lazy components in Suspense
if (customComponent && asyncComponentCache.has(tag)) {
return (
<Suspense key={key} fallback={null}>
{React.createElement(Component, props, ...renderedChildren)}
</Suspense>
)
}
return React.createElement(Component, props, ...renderedChildren)
}
return null
}
export interface MDCRendererProps {
/**
* The Minimark tree to render
*/
body: MinimarkTree
/**
* Custom component mappings for element tags
* Key: tag name (e.g., 'h1', 'p', 'MyComponent')
* Value: React component
*/
components?: Record<string, React.ComponentType<any>>
/**
* Dynamic component resolver function
* Used to resolve components that aren't in the components map
*/
componentsManifest?: (name: string) => Promise<{ default: React.ComponentType<any> }>
/**
* Enable streaming mode with stream-specific components
*/
streaming?: boolean
/**
* If caret is true, a caret will be appended to the last text node in the tree
* If caret is an object, it will be appended to the last text node in the tree with the given class
*/
caret?: boolean | { class: string }
/**
* Additional className for the wrapper div
*/
className?: string
}
/**
* MDCRenderer component
*
* Renders a Minimark tree to React components/HTML.
* Supports custom component mapping for element tags.
*
* @example
* ```tsx
* import { MDCRenderer } from 'mdc-syntax/react'
* import CustomHeading from './CustomHeading'
*
* const customComponents = {
* h1: CustomHeading,
* h2: CustomHeading,
* }
*
* export default function App() {
* return <MDCRenderer body={mdcAst} components={customComponents} />
* }
* ```
*/
export const MDCRenderer: React.FC<MDCRendererProps> = ({
body,
components: customComponents = {},
componentsManifest,
streaming = false,
caret: caretProp = false,
className,
}) => {
const components = useMemo(() => ({
...standardProseComponents,
...customComponents,
}), [customComponents])
const caret = useMemo(() => getCaret(caretProp), [caretProp])
const renderedNodes = useMemo(() => {
const nodes = [...(body.value || [])]
if (streaming && caret && nodes.length > 0) {
const hasStreamCaret = findLastTextNodeAndAppendNode(nodes[nodes.length - 1] as MinimarkElement, caret)
if (!hasStreamCaret) {
nodes.push(caret)
}
}
return nodes
.map((node, index) => renderNode(node, components, index, componentsManifest))
.filter(child => child !== null)
}, [body, components, componentsManifest, streaming, caret])
return (
<div className={`mdc-content ${className || ''}`}>
{renderedNodes}
</div>
)
}