710a70bf0e
Fix 500 error on vec docs because we were accessing prototype methods on an object ### Change type - [x] `other`
238 lines
6.6 KiB
TypeScript
238 lines
6.6 KiB
TypeScript
'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>
|
|
)
|
|
}
|