transform-renderer.tsx255 lines · main
1import { LazyMotion, m } from 'framer-motion'
2import React, { PropsWithChildren } from 'react'
3import { createElement, SyntaxHighlighterProps } from 'react-syntax-highlighter'
4
5// Make sure to return the specific export containing the feature bundle.
6const loadFramerFeatures = () => import('./framer-features').then((res) => res.default)
7
8type RendererElementNode = {
9 type: 'element'
10 tagName: keyof React.JSX.IntrinsicElements | React.ComponentType<any>
11 properties: { className: any[]; [key: string]: any }
12 children: RendererNode[]
13}
14
15type RendererTextNode = {
16 type: 'text'
17 value: string | number
18}
19
20type RendererNode = RendererElementNode | RendererTextNode
21
22export type WrapperProps = PropsWithChildren<{
23 text: string
24}>
25
26export type TransformRendererProps = {
27 search: (text: string) => boolean
28 wrapper: ({ children, text }: WrapperProps) => React.JSX.Element
29}
30
31/**
32 * Transforms the output of the syntax highlighter.
33 *
34 * It uses the provided `search` function to identify which
35 * HTML elements contain the desired text. It then wraps those
36 * elements using the provided `wrapper` React component.
37 *
38 * Useful for adding interactions within the code block,
39 * such as clickable URLs.
40 */
41export function transformRenderer({
42 search,
43 wrapper,
44}: TransformRendererProps): SyntaxHighlighterProps['renderer'] {
45 return ({ rows, stylesheet, useInlineStyles }) => {
46 const newRows = (rows as RendererNode[]).map((node, i) => {
47 const element = createElement({
48 node,
49 stylesheet,
50 useInlineStyles,
51 key: `code-segment-${i}`,
52 })
53
54 if (node.type === 'text') {
55 return element
56 }
57
58 const line = mergeValues([node])
59
60 if (!search(line)) {
61 return (
62 <m.div key={line} layoutId={line}>
63 {element}
64 </m.div>
65 )
66 }
67
68 const children = splitSpaceElements(node.children)
69
70 let startIndex = -1
71
72 while (search(mergeValues(children.slice(startIndex + 1)))) {
73 startIndex++
74 }
75
76 let endIndex = children.length
77
78 while (search(mergeValues(children.slice(startIndex, endIndex - 1)))) {
79 endIndex--
80 }
81
82 const text = mergeValues(children.slice(startIndex, endIndex))
83
84 const nodeChildren: RendererNode[] = []
85 const wrapperElement = elementToRendererElementNode(wrapper, text)
86
87 for (let i = 0; i < children.length; i++) {
88 const child = children[i]
89
90 if (i < startIndex || i >= endIndex) {
91 nodeChildren.push(child)
92 } else {
93 if (i === startIndex) {
94 nodeChildren.push(wrapperElement)
95 }
96 wrapperElement.children.push(child)
97 }
98 }
99
100 node.children = nodeChildren
101
102 const reactElement = createElement({
103 node,
104 stylesheet,
105 useInlineStyles,
106 key: `code-segment-${i}`,
107 })
108
109 return (
110 <m.div key={line} layoutId={line}>
111 {reactElement}
112 </m.div>
113 )
114 })
115
116 return <LazyMotion features={loadFramerFeatures}>{newRows}</LazyMotion>
117 }
118}
119
120/**
121 * Recursively merges text nodes into a single string.
122 */
123function mergeValues(nodes: RendererNode[]): string {
124 return nodes
125 .map((node) => {
126 const { type } = node
127
128 if (type === 'text') {
129 return node.value.toString()
130 } else if (type === 'element') {
131 return mergeValues(node.children)
132 } else {
133 throw new Error(`Unknown node type '${type}'`)
134 }
135 })
136 .join('')
137}
138
139/**
140 * Searches for space characters at the start or end of an element's text nodes
141 * and splits them out into their own element + text node.
142 */
143function splitSpaceElements(nodes: RendererNode[]) {
144 const children: RendererNode[] = []
145
146 for (const child of nodes) {
147 const newChild = { ...child }
148
149 if (newChild.type === 'element') {
150 let splitNodes = splitSpaceElements(newChild.children)
151
152 if (splitNodes.length === 1) {
153 children.push({
154 ...newChild,
155 children: splitNodes,
156 })
157 continue
158 }
159
160 const [firstNode] = splitNodes.slice(0, 1)
161 const [lastNode] = splitNodes.slice(-1)
162
163 let newFirstNode: RendererElementNode | undefined = undefined
164 let newLastNode: RendererElementNode | undefined = undefined
165
166 if (firstNode.type === 'text' && firstNode.value.toString().match(/^[ ]+$/)) {
167 newFirstNode = {
168 ...newChild,
169 children: [firstNode],
170 }
171 splitNodes.shift()
172 }
173
174 if (lastNode.type === 'text' && lastNode.value.toString().match(/^[ ]+$/)) {
175 newLastNode = {
176 ...newChild,
177 children: [lastNode],
178 }
179 splitNodes.pop()
180 }
181
182 if (newFirstNode) {
183 children.push(newFirstNode)
184 }
185
186 children.push({
187 ...newChild,
188 children: splitNodes,
189 })
190
191 if (newLastNode) {
192 children.push(newLastNode)
193 }
194 } else {
195 const stringValue = newChild.value.toString()
196 const startNonWhitespaceIndex = stringValue.search(/[^ ]/)
197 const endNonWhitespaceIndex = stringValue.search(/[^ ][ ]+$/) + 1
198
199 if (startNonWhitespaceIndex > 0) {
200 const whitespaceChild: RendererTextNode = {
201 ...newChild,
202 value: stringValue.substring(0, startNonWhitespaceIndex),
203 }
204 children.push(whitespaceChild)
205 }
206
207 if (startNonWhitespaceIndex > 0 || endNonWhitespaceIndex > 0) {
208 const nonWhitespaceChild: RendererTextNode = {
209 ...newChild,
210 value: stringValue.substring(
211 startNonWhitespaceIndex > 0 ? startNonWhitespaceIndex : 0,
212 endNonWhitespaceIndex > 0 ? endNonWhitespaceIndex : undefined
213 ),
214 }
215 children.push(nonWhitespaceChild)
216 } else {
217 children.push(newChild)
218 }
219
220 if (endNonWhitespaceIndex > 0) {
221 const whitespaceChild: RendererTextNode = {
222 ...newChild,
223 value: stringValue.substring(endNonWhitespaceIndex),
224 }
225 children.push(whitespaceChild)
226 }
227 }
228 }
229
230 return children
231}
232
233/**
234 * Converts a JSX element to a RendererElementNode
235 * that the syntax highlighter can work with.
236 */
237function elementToRendererElementNode(
238 element: ({ children, text }: WrapperProps) => React.JSX.Element,
239 text: string
240): RendererElementNode {
241 const {
242 type,
243 props: { children, className, ...props },
244 } = element({ children: [], text })
245
246 return {
247 type: 'element',
248 tagName: type,
249 properties: {
250 className: className ? [className] : [],
251 ...props,
252 },
253 children,
254 }
255}