'use client' import { SandpackCodeViewer, SandpackFiles, SandpackProvider } from '@codesandbox/sandpack-react' import { getOwnProperty } from '@tldraw/utils' import { useTheme } from 'next-themes' import React, { Fragment, ReactElement, ReactNode, cloneElement, createContext, isValidElement, useContext, useEffect, useMemo, useState, } from 'react' import { A } from './generic' const CodeLinksContext = createContext<Record<string, string>>({}) export function CodeLinkProvider({ children, links, }: { children: React.ReactNode links: Record<string, string> }) { return <CodeLinksContext.Provider value={links}>{children}</CodeLinksContext.Provider> } export function Code({ children, ...props }: React.ComponentProps<'code'>) { const codeLinks = useContext(CodeLinksContext) const newChildren = useMemo(() => { // to linkify code, we have to do quite a lot of work. we need to take the output of // Highlight.js and transform it to add hyperlinks to certain tokens. There are a few things // that make this difficult: // // 1, the structure is recursive. A function span will include a bunch of other spans making // up the whole definition of the function, for example. // // 2, a given span doesn't necessarily correspond to a single identifier. For example, this // code: `dispatch: (info: TLEventInfo) => this` will be split like this: // - `dispatch` // - `: (info: TLEventInfo) => ` // - `this` // // That means we need to take highlight.js's tokens and split them into our own tokens that // contain single identifiers to linkify. // // 3, a single identifier can be split across multiple spans. For example, // `Omit<Geometry2dOptions>` will be split like this: // - `Omit` // - `<` // - `Geometry2` // - `dOptions` // - `>` // // I don't know why this happens, it feels like a bug. We handle this by keeping track of & // merging consecutive tokens if they're identifiers with no non-identifier tokens in // between. // does this token look like a JS identifier? function isIdentifier(token: string): boolean { return /^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/g.test(token) } // split the code into an array of identifiers, and the bits in between them function tokenize(code: string): string[] { const identifierRegex = /[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*/g let currentIdx = 0 const tokens = [] for (const identifierMatch of code.matchAll(identifierRegex)) { const [identifier] = identifierMatch const idx = identifierMatch.index if (idx > currentIdx) { tokens.push(code.slice(currentIdx, idx)) } tokens.push(identifier) currentIdx = idx + identifier.length } if (currentIdx < code.length) { tokens.push(code.slice(currentIdx)) } return tokens } // recursively process the children array function processChildrenArray(children: ReactNode): ReactNode { if (!Array.isArray(children)) { if (!children) return children return processChildrenArray([children]) } // these are the new linkified children, the result of this function const newChildren = [] // in order to deal with token splitting/merging, we need to keep track of the last // highlight span we saw. this has the right classes on it to colorize the current // token. let lastSeenHighlightSpan: ReactElement | null = null // the current identifier that we're building up by merging consecutive tokens let currentIdentifier: string | null = null // whether the current span is closed, but we're still in the same identifier and might // still need to append to it let isCurrentSpanClosed = false function startSpan(span: ReactElement) { lastSeenHighlightSpan = span isCurrentSpanClosed = false } function closeSpan() { isCurrentSpanClosed = true if (!currentIdentifier) { lastSeenHighlightSpan = null } } function pushInCurrentSpan(content: ReactNode) { if (lastSeenHighlightSpan) { newChildren.push( cloneElement(lastSeenHighlightSpan, { key: newChildren.length, children: content, }) ) } else { newChildren.push(<Fragment key={newChildren.length}>{content}</Fragment>) } } function finishCurrentIdentifier() { if (currentIdentifier) { const link = getOwnProperty(codeLinks, currentIdentifier) if (link) { pushInCurrentSpan( <A href={link} className="code-link"> {currentIdentifier} </A> ) } else { pushInCurrentSpan(currentIdentifier) } currentIdentifier = null } if (isCurrentSpanClosed) { lastSeenHighlightSpan = null } } function pushToken(token: string) { if (isIdentifier(token)) { if (currentIdentifier) { currentIdentifier += token } else { currentIdentifier = token } } else { finishCurrentIdentifier() pushInCurrentSpan(token) } } for (const child of children) { if (typeof child === 'string') { for (const token of tokenize(child)) { pushToken(token) } } else if (isValidElement<{ children: ReactNode }>(child)) { if (child.type === 'span' && typeof child.props.children === 'string') { startSpan(child) for (const token of tokenize(child.props.children)) { pushToken(token) } closeSpan() } else { finishCurrentIdentifier() newChildren.push( cloneElement(child, { key: newChildren.length, children: processChildrenArray(child.props.children), }) ) } } else { throw new Error(`Invalid code child: ${JSON.stringify(child)}`) } } finishCurrentIdentifier() return newChildren } return processChildrenArray(children) }, [children, codeLinks]) return <code {...props}>{newChildren}</code> } export function CodeBlock({ code }: { code: SandpackFiles }) { const [isClientSide, setIsClientSide] = useState(false) const { theme } = useTheme() useEffect(() => setIsClientSide(true), []) // This is to avoid hydration mismatch between the server and the client because of the useTheme. if (!isClientSide) { return null } const trimmedCode = Object.fromEntries( Object.entries(code).map(([key, value]) => [key, (value as string).trim()]) ) return ( <div className="code-example"> <SandpackProvider className="sandpack" key={`sandpack-${theme}`} template="react-ts" options={{ activeFile: Object.keys(code)[0] }} customSetup={{ dependencies: { '@tldraw/assets': 'latest', tldraw: 'latest', }, }} files={trimmedCode} theme={theme === 'dark' ? 'dark' : 'light'} > <SandpackCodeViewer /> </SandpackProvider> </div> ) }