@lexibase/rdx-ui 2.0.0 → 3.0.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.
@@ -1,79 +1,166 @@
1
1
  'use client'
2
2
 
3
- import { useMessages } from '../providers/locale-provider'
4
-
5
- // shadcn/ui Base Nova Dialog, using the shared RDX theme and buttons.
6
3
  import * as React from 'react'
7
4
  import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
8
- import { X } from 'lucide-react'
9
- import { mergeStateClassName } from '../lib/utils'
5
+ import { cn, mergeStateClassName } from '../lib/utils'
6
+
10
7
  import { Button } from './button'
8
+ import { XIcon } from 'lucide-react'
9
+ import { useMessages } from '../providers/locale-provider'
11
10
 
12
- function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
13
- return <DialogPrimitive.Root {...props} />
11
+ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
12
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />
14
13
  }
15
- function DialogTrigger(
16
- props: React.ComponentProps<typeof DialogPrimitive.Trigger>
17
- ) {
14
+
15
+ function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
18
16
  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
19
17
  }
18
+
19
+ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
20
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
21
+ }
22
+
23
+ function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
24
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
25
+ }
26
+
27
+ function DialogOverlay({
28
+ className,
29
+ ...props
30
+ }: DialogPrimitive.Backdrop.Props) {
31
+ return (
32
+ <DialogPrimitive.Backdrop
33
+ data-slot="dialog-overlay"
34
+ className={mergeStateClassName(
35
+ 'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 motion-reduce:data-open:animate-none motion-reduce:data-closed:animate-none',
36
+ className
37
+ )}
38
+ {...props}
39
+ />
40
+ )
41
+ }
42
+
20
43
  function DialogContent({
21
44
  className,
22
45
  children,
46
+ showCloseButton = true,
47
+ closeLabel,
48
+ overlayClassName,
23
49
  ...props
24
- }: React.ComponentProps<typeof DialogPrimitive.Popup>) {
50
+ }: DialogPrimitive.Popup.Props & {
51
+ showCloseButton?: boolean
52
+ closeLabel?: string
53
+ overlayClassName?: DialogPrimitive.Backdrop.Props['className']
54
+ }) {
25
55
  const messages = useMessages()
26
56
  return (
27
- <DialogPrimitive.Portal>
28
- <DialogPrimitive.Backdrop
29
- data-slot="dialog-overlay"
30
- className="fixed inset-0 isolate z-50 bg-black/30 backdrop-blur-xs duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 motion-reduce:data-open:animate-none motion-reduce:data-closed:animate-none"
31
- />
57
+ <DialogPortal>
58
+ <DialogOverlay className={overlayClassName} />
32
59
  <DialogPrimitive.Popup
33
60
  data-slot="dialog-content"
34
61
  className={mergeStateClassName(
35
- 'fixed top-[20%] left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 gap-4 rounded-xl bg-popover p-1 text-sm text-popover-foreground ring-1 ring-foreground/10 shadow-lg outline-none duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-reduce:data-open:animate-none motion-reduce:data-closed:animate-none',
62
+ 'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-open:animate-none motion-reduce:data-closed:animate-none',
36
63
  className
37
64
  )}
38
65
  {...props}
39
66
  >
40
67
  {children}
41
- <DialogPrimitive.Close
42
- render={
43
- <Button
44
- variant="ghost"
45
- size="icon"
46
- className="absolute top-2 right-2 size-7"
47
- />
48
- }
49
- aria-label={messages.closeSearch}
50
- >
51
- <X className="size-3.5" />
52
- </DialogPrimitive.Close>
68
+ {showCloseButton && (
69
+ <DialogPrimitive.Close
70
+ data-slot="dialog-close"
71
+ render={
72
+ <Button
73
+ variant="ghost"
74
+ className="absolute top-2 right-2"
75
+ size="icon-sm"
76
+ />
77
+ }
78
+ >
79
+ <XIcon />
80
+ <span className="sr-only">{closeLabel ?? messages.close}</span>
81
+ </DialogPrimitive.Close>
82
+ )}
53
83
  </DialogPrimitive.Popup>
54
- </DialogPrimitive.Portal>
84
+ </DialogPortal>
85
+ )
86
+ }
87
+
88
+ function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
89
+ return (
90
+ <div
91
+ data-slot="dialog-header"
92
+ className={cn('flex flex-col gap-2', className)}
93
+ {...props}
94
+ />
55
95
  )
56
96
  }
57
- function DialogTitle({
97
+
98
+ function DialogFooter({
58
99
  className,
100
+ showCloseButton = false,
101
+ children,
59
102
  ...props
60
- }: React.ComponentProps<typeof DialogPrimitive.Title>) {
103
+ }: React.ComponentProps<'div'> & {
104
+ showCloseButton?: boolean
105
+ }) {
106
+ const messages = useMessages()
107
+ return (
108
+ <div
109
+ data-slot="dialog-footer"
110
+ className={cn(
111
+ '-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
112
+ className
113
+ )}
114
+ {...props}
115
+ >
116
+ {children}
117
+ {showCloseButton && (
118
+ <DialogPrimitive.Close render={<Button variant="outline" />}>
119
+ {messages.close}
120
+ </DialogPrimitive.Close>
121
+ )}
122
+ </div>
123
+ )
124
+ }
125
+
126
+ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
61
127
  return (
62
128
  <DialogPrimitive.Title
63
- className={mergeStateClassName('font-medium', className)}
129
+ data-slot="dialog-title"
130
+ className={mergeStateClassName(
131
+ 'cn-font-heading text-base leading-none font-medium',
132
+ className
133
+ )}
64
134
  {...props}
65
135
  />
66
136
  )
67
137
  }
138
+
68
139
  function DialogDescription({
69
140
  className,
70
141
  ...props
71
- }: React.ComponentProps<typeof DialogPrimitive.Description>) {
142
+ }: DialogPrimitive.Description.Props) {
72
143
  return (
73
144
  <DialogPrimitive.Description
74
- className={mergeStateClassName('text-muted-foreground', className)}
145
+ data-slot="dialog-description"
146
+ className={mergeStateClassName(
147
+ 'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
148
+ className
149
+ )}
75
150
  {...props}
76
151
  />
77
152
  )
78
153
  }
79
- export { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogDescription }
154
+
155
+ export {
156
+ Dialog,
157
+ DialogClose,
158
+ DialogContent,
159
+ DialogDescription,
160
+ DialogFooter,
161
+ DialogHeader,
162
+ DialogOverlay,
163
+ DialogPortal,
164
+ DialogTitle,
165
+ DialogTrigger,
166
+ }
@@ -99,7 +99,12 @@ export function DocsSearchDialog({
99
99
  }
100
100
  return (
101
101
  <Dialog open={open} onOpenChange={onOpenChange}>
102
- <DialogContent finalFocus={triggerRef}>
102
+ <DialogContent
103
+ finalFocus={triggerRef}
104
+ closeLabel={messages.closeSearch}
105
+ overlayClassName="bg-black/30"
106
+ className="top-[20%] w-[calc(100%-2rem)] max-w-lg translate-y-0 p-1 shadow-lg sm:max-w-lg"
107
+ >
103
108
  <DialogTitle className="sr-only">
104
109
  {messages.searchDocumentation}
105
110
  </DialogTitle>
@@ -0,0 +1,77 @@
1
+ 'use client'
2
+
3
+ import { useState, type ComponentProps, type ReactNode } from 'react'
4
+ import { CodeXml, FolderTree, GitCompareArrows, WrapText } from 'lucide-react'
5
+
6
+ import { useMessages } from '../providers/locale-provider'
7
+ import { Button } from './button'
8
+ import {
9
+ CodeBlock,
10
+ CodeBlockActions,
11
+ CodeBlockContent,
12
+ CodeBlockCopyButton,
13
+ CodeBlockHeader,
14
+ CodeBlockTitle,
15
+ } from './code-block'
16
+
17
+ /** The wrap action follows shadcn's stateful Code Block composition. */
18
+ export function MdxCodeBlockView({
19
+ label,
20
+ mode,
21
+ value,
22
+ preProps,
23
+ children,
24
+ }: {
25
+ label: string
26
+ mode?: 'folder' | 'diff'
27
+ value: string
28
+ preProps: ComponentProps<'pre'>
29
+ children: ReactNode
30
+ }) {
31
+ const [wrap, setWrap] = useState(false)
32
+ const messages = useMessages()
33
+ const Icon =
34
+ mode === 'folder'
35
+ ? FolderTree
36
+ : mode === 'diff'
37
+ ? GitCompareArrows
38
+ : CodeXml
39
+ return (
40
+ <CodeBlock
41
+ size="sm"
42
+ data-rdx-code-block
43
+ data-rdx-code-mode={mode}
44
+ data-rdx-code-value={value}
45
+ className="my-4 max-w-full"
46
+ >
47
+ <CodeBlockHeader>
48
+ <CodeBlockTitle>
49
+ <Icon aria-hidden="true" /> {label}
50
+ </CodeBlockTitle>
51
+ <CodeBlockActions>
52
+ <Button
53
+ variant="ghost"
54
+ size="icon-xs"
55
+ className="md:hidden"
56
+ aria-label={messages.wrapCode}
57
+ title={messages.wrapCode}
58
+ aria-pressed={wrap}
59
+ onClick={() => setWrap(!wrap)}
60
+ >
61
+ <WrapText aria-hidden="true" />
62
+ </Button>
63
+ <CodeBlockCopyButton value={value} />
64
+ </CodeBlockActions>
65
+ </CodeBlockHeader>
66
+ <CodeBlockContent
67
+ {...preProps}
68
+ wrap={wrap}
69
+ className={[preProps.className, 'data-wrap:wrap-anywhere']
70
+ .filter(Boolean)
71
+ .join(' ')}
72
+ >
73
+ {children}
74
+ </CodeBlockContent>
75
+ </CodeBlock>
76
+ )
77
+ }
@@ -0,0 +1,108 @@
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ isValidElement,
5
+ type ComponentProps,
6
+ type ReactNode,
7
+ } from 'react'
8
+
9
+ import { getMessages } from '../lib/i18n'
10
+ import { CodeBlockLine } from './code-block'
11
+ import { MdxCodeBlockView } from './mdx-code-block-view'
12
+
13
+ function textOf(children: ReactNode): string {
14
+ return Children.toArray(children)
15
+ .map((child) =>
16
+ typeof child === 'string' || typeof child === 'number'
17
+ ? String(child)
18
+ : isValidElement<{ children?: ReactNode }>(child)
19
+ ? textOf(child.props.children)
20
+ : ''
21
+ )
22
+ .join('')
23
+ }
24
+
25
+ // Shadcn draws diff markers itself. Strip the original marker from highlighted
26
+ // tokens while keeping the original patch as the clipboard value.
27
+ function withoutMarker(children: ReactNode): ReactNode {
28
+ let remaining = 1
29
+ function strip(nodes: ReactNode): ReactNode {
30
+ return Children.map(nodes, (node) => {
31
+ if (!remaining) return node
32
+ if (typeof node === 'string') {
33
+ const value = node.slice(remaining)
34
+ remaining = Math.max(0, remaining - node.length)
35
+ return value
36
+ }
37
+ return isValidElement<{ children?: ReactNode }>(node)
38
+ ? cloneElement(node, {}, strip(node.props.children))
39
+ : node
40
+ })
41
+ }
42
+ return strip(children)
43
+ }
44
+
45
+ /** Translate Markdown fences to the complete shadcn Code Block composition. */
46
+ export function MdxCodeBlock({
47
+ lang,
48
+ children,
49
+ 'data-rdx-code-mode': mode,
50
+ ...props
51
+ }: ComponentProps<'pre'> & { 'data-rdx-code-mode'?: 'folder' | 'diff' }) {
52
+ const messages = getMessages(lang)
53
+ const code = Children.toArray(children).find((child) => isValidElement(child))
54
+ const codeProps = isValidElement<{
55
+ className?: string
56
+ children?: ReactNode
57
+ }>(code)
58
+ ? code.props
59
+ : undefined
60
+ const language = codeProps?.className?.match(/(?:^|\s)language-([^\s]+)/)?.[1]
61
+ const value = textOf(codeProps?.children ?? children)
62
+ const label =
63
+ mode === 'folder' ? messages.folderTree : (language ?? messages.code)
64
+ 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)
93
+ return (
94
+ <MdxCodeBlockView
95
+ label={label}
96
+ mode={mode}
97
+ value={value}
98
+ preProps={{
99
+ ...props,
100
+ className: [props.className, language && `language-${language}`]
101
+ .filter(Boolean)
102
+ .join(' '),
103
+ }}
104
+ >
105
+ {content}
106
+ </MdxCodeBlockView>
107
+ )
108
+ }
@@ -1,7 +1,14 @@
1
1
  import { getMessages } from '../lib/i18n'
2
- import type { ComponentProps } from 'react'
2
+ import {
3
+ Children,
4
+ cloneElement,
5
+ isValidElement,
6
+ type ComponentProps,
7
+ type ReactNode,
8
+ } from 'react'
3
9
 
4
10
  import { cn } from '../lib/utils'
11
+ import { MdxImage } from './mdx-image'
5
12
  import {
6
13
  Accordion,
7
14
  AccordionContent,
@@ -10,7 +17,17 @@ import {
10
17
  } from './accordion'
11
18
  import { Blockquote } from './blockquote'
12
19
  import { Callout } from './callout'
13
- import { CodeBlock } from './code-block'
20
+ import {
21
+ CodeBlock,
22
+ CodeBlockActions,
23
+ CodeBlockContent,
24
+ CodeBlockCopyButton,
25
+ CodeBlockFooter,
26
+ CodeBlockHeader,
27
+ CodeBlockLine,
28
+ CodeBlockTitle,
29
+ } from './code-block'
30
+ import { MdxCodeBlock } from './mdx-code-block'
14
31
  import { Kbd, KbdGroup } from './kbd'
15
32
  import {
16
33
  Table,
@@ -25,6 +42,17 @@ import {
25
42
  import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs'
26
43
  import { Separator } from '@lexibase/rdx-ui/components/separator'
27
44
 
45
+ function linkedImages(children: ReactNode): ReactNode {
46
+ return Children.map(children, (child) => {
47
+ if (!isValidElement<{ children?: ReactNode; zoom?: boolean }>(child))
48
+ return child
49
+ if (child.type === MdxImage) return cloneElement(child, { zoom: false })
50
+ return child.props.children
51
+ ? cloneElement(child, {}, linkedImages(child.props.children))
52
+ : child
53
+ })
54
+ }
55
+
28
56
  function MdxTable({ lang, className, ...props }: ComponentProps<'table'>) {
29
57
  return (
30
58
  <Table
@@ -76,14 +104,16 @@ export const mdxComponents = {
76
104
  {...props}
77
105
  />
78
106
  ),
79
- a: ({ className, ...props }: ComponentProps<'a'>) => (
107
+ a: ({ className, children, ...props }: ComponentProps<'a'>) => (
80
108
  <a
81
109
  className={cn('underline text-chart-1 text-blue-500', className)}
82
110
  {...props}
83
- />
111
+ >
112
+ {linkedImages(children)}
113
+ </a>
84
114
  ),
85
115
  blockquote: Blockquote,
86
- pre: CodeBlock,
116
+ pre: MdxCodeBlock,
87
117
  code: ({ className, ...props }: ComponentProps<'code'>) => (
88
118
  <code
89
119
  className={cn(
@@ -106,13 +136,7 @@ export const mdxComponents = {
106
136
  hr: ({ className, ...props }: ComponentProps<'hr'>) => (
107
137
  <hr className={cn('my-8 border-border', className)} {...props} />
108
138
  ),
109
- img: ({ className, alt = '', ...props }: ComponentProps<'img'>) => (
110
- <img
111
- alt={alt}
112
- className={cn('max-w-full h-auto rounded-md my-4', className)}
113
- {...props}
114
- />
115
- ),
139
+ img: MdxImage,
116
140
  table: MdxTable,
117
141
  thead: TableHeader,
118
142
  tbody: TableBody,
@@ -123,6 +147,14 @@ export const mdxComponents = {
123
147
  <TableHead scope={scope} className={className} {...props} />
124
148
  ),
125
149
  td: TableCell,
150
+ CodeBlock,
151
+ CodeBlockActions,
152
+ CodeBlockContent,
153
+ CodeBlockCopyButton,
154
+ CodeBlockFooter,
155
+ CodeBlockHeader,
156
+ CodeBlockLine,
157
+ CodeBlockTitle,
126
158
  Kbd,
127
159
  KbdGroup,
128
160
  Table,
@@ -158,7 +190,9 @@ export const mdxComponents = {
158
190
  export function getMdxComponents(lang?: string) {
159
191
  return {
160
192
  ...mdxComponents,
161
- pre: (props: ComponentProps<'pre'>) => <CodeBlock {...props} lang={lang} />,
193
+ pre: (props: ComponentProps<'pre'>) => (
194
+ <MdxCodeBlock {...props} lang={lang} />
195
+ ),
162
196
  table: (props: ComponentProps<'table'>) => (
163
197
  <MdxTable {...props} lang={lang} />
164
198
  ),
@@ -0,0 +1,73 @@
1
+ 'use client'
2
+
3
+ import type { ComponentProps } from 'react'
4
+
5
+ import { formatMessage } from '../lib/i18n'
6
+ import { cn } from '../lib/utils'
7
+ import { useMessages } from '../providers/locale-provider'
8
+ import { Button } from './button'
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogTitle,
14
+ DialogTrigger,
15
+ } from './dialog'
16
+
17
+ /** Markdown images keep their normal appearance and use shadcn Dialog to expand. */
18
+ export function MdxImage({
19
+ className,
20
+ alt = '',
21
+ zoom = true,
22
+ ...props
23
+ }: ComponentProps<'img'> & { zoom?: boolean }) {
24
+ const messages = useMessages()
25
+ const image = (
26
+ <img
27
+ {...props}
28
+ alt={alt}
29
+ className={cn('max-w-full h-auto rounded-md my-4', className)}
30
+ />
31
+ )
32
+ // Decorative images and images inside links retain their original semantics.
33
+ if (!zoom || !alt.trim() || !props.src) return image
34
+ return (
35
+ <Dialog>
36
+ <DialogTrigger
37
+ data-rdx-image-trigger
38
+ aria-label={formatMessage(messages.enlargeImage, { alt })}
39
+ title={formatMessage(messages.enlargeImage, { alt })}
40
+ render={
41
+ <Button
42
+ variant="ghost"
43
+ className="h-auto max-w-full cursor-zoom-in p-0 align-middle hover:bg-transparent dark:hover:bg-transparent"
44
+ />
45
+ }
46
+ >
47
+ {image}
48
+ </DialogTrigger>
49
+ <DialogContent
50
+ data-rdx-image-dialog
51
+ closeLabel={messages.closeImage}
52
+ overlayClassName="bg-black/70"
53
+ className="flex max-h-[calc(100dvh-2rem)] w-max max-w-[calc(100vw-2rem)] flex-col gap-0 p-2 sm:max-w-[calc(100vw-4rem)] [&>[data-slot=dialog-close]]:bg-popover/90"
54
+ >
55
+ <DialogTitle className="sr-only">{alt}</DialogTitle>
56
+ <DialogDescription className="sr-only">
57
+ {messages.imagePreviewDescription}
58
+ </DialogDescription>
59
+ <img
60
+ src={props.src}
61
+ srcSet={props.srcSet}
62
+ sizes={props.srcSet ? 'calc(100vw - 4rem)' : undefined}
63
+ crossOrigin={props.crossOrigin}
64
+ referrerPolicy={props.referrerPolicy}
65
+ alt={alt}
66
+ loading="eager"
67
+ decoding="async"
68
+ className="h-auto max-h-[calc(100dvh-4rem)] w-auto max-w-full self-center rounded-md object-contain"
69
+ />
70
+ </DialogContent>
71
+ </Dialog>
72
+ )
73
+ }
@@ -25,8 +25,13 @@ function panelText(node: Node): string {
25
25
  )
26
26
  )
27
27
  return ''
28
- if (node.matches('[data-rdx-code-block]')) {
29
- return `${node.querySelector('pre')?.textContent ?? ''}\n`
28
+ if (node.matches('[data-rdx-code-block], [data-slot="code-block"]')) {
29
+ const pre = node.querySelector('pre')
30
+ const lines = pre?.querySelectorAll('[data-slot="code-block-line-content"]')
31
+ const text = lines?.length
32
+ ? Array.from(lines, (line) => line.textContent ?? '').join('\n')
33
+ : pre?.textContent
34
+ return `${node.dataset.rdxCodeValue ?? text ?? ''}\n`
30
35
  }
31
36
  if (node.tagName === 'PRE') return `${node.textContent ?? ''}\n`
32
37
  if (node.tagName === 'BR') return '\n'
@@ -0,0 +1,75 @@
1
+ import * as React from 'react'
2
+
3
+ import { loadHighlighter } from '../lib/highlighter'
4
+
5
+ export function useHighlightedCode(value: string, language: string) {
6
+ // A trailing newline would render as an extra empty line.
7
+ const code = value.endsWith('\n') ? value.slice(0, -1) : value
8
+ const [loaded, setLoaded] = React.useState<{
9
+ language: string
10
+ highlighter: Awaited<ReturnType<typeof loadHighlighter>>
11
+ } | null>(null)
12
+ const highlighter = loaded?.language === language ? loaded.highlighter : null
13
+
14
+ React.useEffect(() => {
15
+ let cancelled = false
16
+
17
+ loadHighlighter(language)
18
+ .then((next) => {
19
+ if (!cancelled) {
20
+ setLoaded({ language, highlighter: next })
21
+ }
22
+ })
23
+ .catch(() => {})
24
+
25
+ return () => {
26
+ cancelled = true
27
+ }
28
+ }, [language])
29
+
30
+ // The complete lines, highlighted. While the code streams, only the new
31
+ // lines are tokenized, starting from the grammar state of the last one.
32
+ const [done, setDone] = React.useState<{
33
+ highlighter: typeof highlighter
34
+ text: string
35
+ lines: React.ReactNode[]
36
+ state?: Parameters<NonNullable<typeof highlighter>['highlight']>[1]
37
+ }>({ highlighter: null, text: '', lines: [] })
38
+
39
+ let next = done
40
+
41
+ if (next.highlighter !== highlighter || !code.startsWith(next.text)) {
42
+ next = { highlighter, text: '', lines: [] }
43
+ }
44
+
45
+ const end = code.lastIndexOf('\n') + 1
46
+
47
+ if (highlighter && end > next.text.length) {
48
+ const result = highlighter.highlight(
49
+ code.slice(next.text.length, end - 1),
50
+ next.state
51
+ )
52
+
53
+ next = {
54
+ highlighter,
55
+ text: code.slice(0, end),
56
+ lines: [...next.lines, ...result.lines],
57
+ state: result.state,
58
+ }
59
+ }
60
+
61
+ if (next !== done) {
62
+ setDone(next)
63
+ }
64
+
65
+ // The last line is still being written, so it is tokenized on every render.
66
+ const tail = code.slice(end)
67
+
68
+ return React.useMemo(() => {
69
+ if (!highlighter) {
70
+ return code.split('\n')
71
+ }
72
+
73
+ return [...next.lines, highlighter.highlight(tail, next.state).lines[0]]
74
+ }, [code, highlighter, next, tail])
75
+ }