@@ -2,6 +2,7 @@ import { useMarked } from "../context/marked"
22import { useI18n } from "../context/i18n"
33import DOMPurify from "dompurify"
44import morphdom from "morphdom"
5+ import { marked , type Tokens } from "marked"
56import { checksum } from "@opencode-ai/util/encode"
67import { ComponentProps , createEffect , createResource , createSignal , onCleanup , splitProps } from "solid-js"
78import { isServer } from "solid-js/web"
@@ -57,6 +58,47 @@ function fallback(markdown: string) {
5758 return escape ( markdown ) . replace ( / \r \n ? / g, "\n" ) . replace ( / \n / g, "<br>" )
5859}
5960
61+ type Block = {
62+ raw : string
63+ mode : "full" | "live"
64+ }
65+
66+ function references ( markdown : string ) {
67+ return / ^ \[ [ ^ \] ] + \] : \s + \S + / m. test ( markdown ) || / ^ \[ \^ [ ^ \] ] + \] : \s + / m. test ( markdown )
68+ }
69+
70+ function incomplete ( raw : string ) {
71+ const open = raw . match ( / ^ [ \t ] { 0 , 3 } ( ` { 3 , } | ~ { 3 , } ) / )
72+ if ( ! open ) return false
73+ const mark = open [ 1 ]
74+ if ( ! mark ) return false
75+ const char = mark [ 0 ]
76+ const size = mark . length
77+ const last = raw . trimEnd ( ) . split ( "\n" ) . at ( - 1 ) ?. trim ( ) ?? ""
78+ return ! new RegExp ( `^[\\t ]{0,3}${ char } {${ size } ,}[\\t ]*$` ) . test ( last )
79+ }
80+
81+ function blocks ( markdown : string , streaming : boolean ) {
82+ if ( ! streaming || references ( markdown ) ) return [ { raw : markdown , mode : "full" } ] satisfies Block [ ]
83+ const tokens = marked . lexer ( markdown )
84+ const last = tokens . findLast ( ( token ) => token . type !== "space" )
85+ if ( ! last || last . type !== "code" ) return [ { raw : markdown , mode : "full" } ] satisfies Block [ ]
86+ const code = last as Tokens . Code
87+ if ( ! incomplete ( code . raw ) ) return [ { raw : markdown , mode : "full" } ] satisfies Block [ ]
88+ const head = tokens
89+ . slice (
90+ 0 ,
91+ tokens . findLastIndex ( ( token ) => token . type !== "space" ) ,
92+ )
93+ . map ( ( token ) => token . raw )
94+ . join ( "" )
95+ if ( ! head ) return [ { raw : code . raw , mode : "live" } ] satisfies Block [ ]
96+ return [
97+ { raw : head , mode : "full" } ,
98+ { raw : code . raw , mode : "live" } ,
99+ ] satisfies Block [ ]
100+ }
101+
60102type CopyLabels = {
61103 copy : string
62104 copied : string
@@ -180,10 +222,11 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) {
180222 markCodeLinks ( root )
181223}
182224
183- function setupCodeCopy ( root : HTMLDivElement , labels : CopyLabels ) {
225+ function setupCodeCopy ( root : HTMLDivElement , getLabels : ( ) => CopyLabels ) {
184226 const timeouts = new Map < HTMLButtonElement , ReturnType < typeof setTimeout > > ( )
185227
186228 const updateLabel = ( button : HTMLButtonElement ) => {
229+ const labels = getLabels ( )
187230 const copied = button . getAttribute ( "data-copied" ) === "true"
188231 setCopyState ( button , labels , copied )
189232 }
@@ -200,14 +243,15 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
200243 const clipboard = navigator ?. clipboard
201244 if ( ! clipboard ) return
202245 await clipboard . writeText ( content )
246+ const labels = getLabels ( )
203247 setCopyState ( button , labels , true )
204248 const existing = timeouts . get ( button )
205249 if ( existing ) clearTimeout ( existing )
206250 const timeout = setTimeout ( ( ) => setCopyState ( button , labels , false ) , 2000 )
207251 timeouts . set ( button , timeout )
208252 }
209253
210- decorate ( root , labels )
254+ decorate ( root , getLabels ( ) )
211255
212256 const buttons = Array . from ( root . querySelectorAll ( '[data-slot="markdown-copy-button"]' ) )
213257 for ( const button of buttons ) {
@@ -239,44 +283,56 @@ export function Markdown(
239283 props : ComponentProps < "div" > & {
240284 text : string
241285 cacheKey ?: string
286+ streaming ?: boolean
242287 class ?: string
243288 classList ?: Record < string , boolean >
244289 } ,
245290) {
246- const [ local , others ] = splitProps ( props , [ "text" , "cacheKey" , "class" , "classList" ] )
291+ const [ local , others ] = splitProps ( props , [ "text" , "cacheKey" , "streaming" , " class", "classList" ] )
247292 const marked = useMarked ( )
248293 const i18n = useI18n ( )
249294 const [ root , setRoot ] = createSignal < HTMLDivElement > ( )
250295 const [ html ] = createResource (
251- ( ) => local . text ,
252- async ( markdown ) => {
253- if ( isServer ) return fallback ( markdown )
254-
255- const hash = checksum ( markdown )
256- const key = local . cacheKey ?? hash
257-
258- if ( key && hash ) {
259- const cached = cache . get ( key )
260- if ( cached && cached . hash === hash ) {
261- touch ( key , cached )
262- return cached . html
263- }
264- }
265-
266- const next = await marked . parse ( markdown )
267- const safe = sanitize ( next )
268- if ( key && hash ) touch ( key , { hash, html : safe } )
269- return safe
296+ ( ) => ( {
297+ text : local . text ,
298+ key : local . cacheKey ,
299+ streaming : local . streaming ?? false ,
300+ } ) ,
301+ async ( src ) => {
302+ if ( isServer ) return fallback ( src . text )
303+ if ( ! src . text ) return ""
304+
305+ const base = src . key ?? checksum ( src . text )
306+ return Promise . all (
307+ blocks ( src . text , src . streaming ) . map ( async ( block , index ) => {
308+ const hash = checksum ( block . raw )
309+ const key = base ? `${ base } :${ index } :${ block . mode } ` : hash
310+
311+ if ( key && hash ) {
312+ const cached = cache . get ( key )
313+ if ( cached && cached . hash === hash ) {
314+ touch ( key , cached )
315+ return cached . html
316+ }
317+ }
318+
319+ const next = await Promise . resolve ( marked . parse ( block . raw ) )
320+ const safe = sanitize ( next )
321+ if ( key && hash ) touch ( key , { hash, html : safe } )
322+ return safe
323+ } ) ,
324+ )
325+ . then ( ( list ) => list . join ( "" ) )
326+ . catch ( ( ) => fallback ( src . text ) )
270327 } ,
271- { initialValue : isServer ? fallback ( local . text ) : "" } ,
328+ { initialValue : fallback ( local . text ) } ,
272329 )
273330
274- let copySetupTimer : ReturnType < typeof setTimeout > | undefined
275331 let copyCleanup : ( ( ) => void ) | undefined
276332
277333 createEffect ( ( ) => {
278334 const container = root ( )
279- const content = html ( )
335+ const content = local . text ? ( html . latest ?? html ( ) ?? "" ) : ""
280336 if ( ! container ) return
281337 if ( isServer ) return
282338
@@ -285,33 +341,39 @@ export function Markdown(
285341 return
286342 }
287343
288- const temp = document . createElement ( "div" )
289- temp . innerHTML = content
290- decorate ( temp , {
344+ const labels = {
291345 copy : i18n . t ( "ui.message.copy" ) ,
292346 copied : i18n . t ( "ui.message.copied" ) ,
293- } )
347+ }
348+ const temp = document . createElement ( "div" )
349+ temp . innerHTML = content
350+ decorate ( temp , labels )
294351
295352 morphdom ( container , temp , {
296353 childrenOnly : true ,
297354 onBeforeElUpdated : ( fromEl , toEl ) => {
355+ if (
356+ fromEl instanceof HTMLButtonElement &&
357+ toEl instanceof HTMLButtonElement &&
358+ fromEl . getAttribute ( "data-slot" ) === "markdown-copy-button" &&
359+ toEl . getAttribute ( "data-slot" ) === "markdown-copy-button" &&
360+ fromEl . getAttribute ( "data-copied" ) === "true"
361+ ) {
362+ setCopyState ( toEl , labels , true )
363+ }
298364 if ( fromEl . isEqualNode ( toEl ) ) return false
299365 return true
300366 } ,
301367 } )
302368
303- if ( copySetupTimer ) clearTimeout ( copySetupTimer )
304- copySetupTimer = setTimeout ( ( ) => {
305- if ( copyCleanup ) copyCleanup ( )
306- copyCleanup = setupCodeCopy ( container , {
369+ if ( ! copyCleanup )
370+ copyCleanup = setupCodeCopy ( container , ( ) => ( {
307371 copy : i18n . t ( "ui.message.copy" ) ,
308372 copied : i18n . t ( "ui.message.copied" ) ,
309- } )
310- } , 150 )
373+ } ) )
311374 } )
312375
313376 onCleanup ( ( ) => {
314- if ( copySetupTimer ) clearTimeout ( copySetupTimer )
315377 if ( copyCleanup ) copyCleanup ( )
316378 } )
317379
0 commit comments