11import { createEffect , createMemo , createSignal , onCleanup , onMount } from "solid-js"
2- import { renderMarkdown , onLanguagesLoaded , decodeHtmlEntities , setMarkdownTheme } from "../lib/markdown"
32import { useGlobalCache } from "../lib/hooks/use-global-cache"
43import type { TextPart , RenderCache } from "../types/message"
54import { getLogger } from "../lib/logger"
65import { copyToClipboard } from "../lib/clipboard"
76import { useI18n } from "../lib/i18n"
7+ import { escapeHtml } from "../lib/text-render-utils"
88
99const log = getLogger ( "session" )
1010
11+ type MarkdownModule = typeof import ( "../lib/markdown" )
12+
13+ let markdownModulePromise : Promise < MarkdownModule > | null = null
14+
15+ function loadMarkdownModule ( ) : Promise < MarkdownModule > {
16+ if ( ! markdownModulePromise ) {
17+ markdownModulePromise = import ( "../lib/markdown" )
18+ }
19+ return markdownModulePromise
20+ }
21+
1122function hashText ( value : string ) : string {
1223 let hash = 2166136261
1324 for ( let index = 0 ; index < value . length ; index ++ ) {
@@ -24,6 +35,24 @@ function resolvePartVersion(part: TextPart, text: string): string {
2435 return `text-${ hashText ( text ) } `
2536}
2637
38+ function decodeHtmlEntitiesLocally ( content : string ) : string {
39+ if ( ! content . includes ( "&" ) || typeof document === "undefined" ) {
40+ return content
41+ }
42+
43+ const textarea = document . createElement ( "textarea" )
44+ textarea . innerHTML = content
45+ return textarea . value
46+ }
47+
48+ function renderFallbackHtml ( content : string ) : string {
49+ if ( ! content ) {
50+ return ""
51+ }
52+
53+ return escapeHtml ( content ) . replace ( / \n / g, "<br />" )
54+ }
55+
2756interface MarkdownProps {
2857 part : TextPart
2958 instanceId ?: string
@@ -38,7 +67,8 @@ export function Markdown(props: MarkdownProps) {
3867 const { t } = useI18n ( )
3968 const [ html , setHtml ] = createSignal ( "" )
4069 let containerRef : HTMLDivElement | undefined
41- let latestRequestedText = ""
70+ let latestRequestKey = ""
71+ let cleanupLanguageListener : ( ( ) => void ) | undefined
4272
4373 const notifyRendered = ( ) => {
4474 Promise . resolve ( ) . then ( ( ) => props . onRendered ?.( ) )
@@ -47,15 +77,16 @@ export function Markdown(props: MarkdownProps) {
4777 const resolved = createMemo ( ( ) => {
4878 const part = props . part
4979 const rawText = typeof part . text === "string" ? part . text : ""
50- const text = decodeHtmlEntities ( rawText )
80+ const text = decodeHtmlEntitiesLocally ( rawText )
5181 const themeKey = Boolean ( props . isDark ) ? "dark" : "light"
5282 const highlightEnabled = ! props . disableHighlight
5383 const partId = typeof part . id === "string" && part . id . length > 0 ? part . id : ""
5484 if ( ! partId ) {
5585 throw new Error ( "Markdown rendering requires a part id" )
5686 }
5787 const version = resolvePartVersion ( part , text )
58- return { part, text, themeKey, highlightEnabled, partId, version }
88+ const requestKey = `${ partId } :${ themeKey } :${ highlightEnabled ? 1 : 0 } :${ version } `
89+ return { part, text, themeKey, highlightEnabled, partId, version, requestKey }
5990 } )
6091
6192 const cacheHandle = useGlobalCache ( {
@@ -69,20 +100,40 @@ export function Markdown(props: MarkdownProps) {
69100 version : ( ) => resolved ( ) . version ,
70101 } )
71102
72- createEffect ( async ( ) => {
73- const { part, text, themeKey, highlightEnabled, version } = resolved ( )
103+ const commitCacheEntry = ( snapshot : ReturnType < typeof resolved > , renderedHtml : string ) => {
104+ const cacheEntry : RenderCache = {
105+ text : snapshot . text ,
106+ html : renderedHtml ,
107+ theme : snapshot . themeKey ,
108+ mode : snapshot . version ,
109+ }
110+ setHtml ( renderedHtml )
111+ cacheHandle . set ( cacheEntry )
112+ notifyRendered ( )
113+ }
114+
115+ const renderSnapshot = async ( snapshot : ReturnType < typeof resolved > ) => {
116+ const markdown = await loadMarkdownModule ( )
117+ markdown . setMarkdownTheme ( snapshot . themeKey === "dark" )
118+ const rendered = await markdown . renderMarkdown ( snapshot . text , {
119+ suppressHighlight : ! snapshot . highlightEnabled ,
120+ } )
74121
75- // Ensure the markdown highlighter theme matches the active UI theme.
76- setMarkdownTheme ( themeKey === "dark" )
122+ if ( latestRequestKey === snapshot . requestKey ) {
123+ commitCacheEntry ( snapshot , rendered )
124+ }
125+ }
77126
78- latestRequestedText = text
127+ createEffect ( ( ) => {
128+ const snapshot = resolved ( )
129+ latestRequestKey = snapshot . requestKey
79130
80131 const cacheMatches = ( cache : RenderCache | undefined ) => {
81132 if ( ! cache ) return false
82- return cache . theme === themeKey && cache . mode === version
133+ return cache . theme === snapshot . themeKey && cache . mode === snapshot . version
83134 }
84135
85- const localCache = part . renderCache
136+ const localCache = snapshot . part . renderCache
86137 if ( localCache && cacheMatches ( localCache ) ) {
87138 setHtml ( localCache . html )
88139 notifyRendered ( )
@@ -96,111 +147,82 @@ export function Markdown(props: MarkdownProps) {
96147 return
97148 }
98149
99- const commitCacheEntry = ( renderedHtml : string ) => {
100- const cacheEntry : RenderCache = { text, html : renderedHtml , theme : themeKey , mode : version }
101- setHtml ( renderedHtml )
102- cacheHandle . set ( cacheEntry )
103- notifyRendered ( )
104- }
105-
106- if ( ! highlightEnabled ) {
107- try {
108- const rendered = await renderMarkdown ( text , { suppressHighlight : true } )
150+ setHtml ( renderFallbackHtml ( snapshot . text ) )
151+ notifyRendered ( )
109152
110- if ( latestRequestedText === text ) {
111- commitCacheEntry ( rendered )
112- }
113- } catch ( error ) {
114- log . error ( "Failed to render markdown:" , error )
115- if ( latestRequestedText === text ) {
116- commitCacheEntry ( text )
117- }
118- }
119- return
120- }
121-
122- try {
123- const rendered = await renderMarkdown ( text )
124- if ( latestRequestedText === text ) {
125- commitCacheEntry ( rendered )
126- }
127- } catch ( error ) {
153+ void renderSnapshot ( snapshot ) . catch ( ( error ) => {
128154 log . error ( "Failed to render markdown:" , error )
129- if ( latestRequestedText === text ) {
130- commitCacheEntry ( text )
155+ if ( latestRequestKey === snapshot . requestKey ) {
156+ commitCacheEntry ( snapshot , renderFallbackHtml ( snapshot . text ) )
131157 }
132- }
158+ } )
133159 } )
134160
135161 onMount ( ( ) => {
136- const handleClick = async ( e : Event ) => {
137- const target = e . target as HTMLElement
162+ const handleClick = async ( event : Event ) => {
163+ const target = event . target as HTMLElement
138164 const copyButton = target . closest ( ".code-block-copy" ) as HTMLButtonElement
139165
140- if ( copyButton ) {
141- e . preventDefault ( )
142- const code = copyButton . getAttribute ( "data-code" )
143- if ( code ) {
144- const decodedCode = decodeURIComponent ( code )
145- const success = await copyToClipboard ( decodedCode )
146- const copyText = copyButton . querySelector ( ".copy-text" )
147- if ( copyText ) {
148- if ( success ) {
149- copyText . textContent = t ( "markdown.codeBlock.copy.copied" )
150- setTimeout ( ( ) => {
151- copyText . textContent = t ( "markdown.codeBlock.copy.label" )
152- } , 2000 )
153- } else {
154- copyText . textContent = t ( "markdown.codeBlock.copy.failed" )
155- setTimeout ( ( ) => {
156- copyText . textContent = t ( "markdown.codeBlock.copy.label" )
157- } , 2000 )
158- }
159- }
160- }
166+ if ( ! copyButton ) {
167+ return
161168 }
162- }
163-
164- containerRef ?. addEventListener ( "click" , handleClick )
165169
166- const cleanupLanguageListener = onLanguagesLoaded ( async ( ) => {
167- if ( props . disableHighlight ) {
170+ event . preventDefault ( )
171+ const code = copyButton . getAttribute ( "data-code" )
172+ if ( ! code ) {
168173 return
169174 }
170175
171- const { part, text, themeKey, version } = resolved ( )
172-
173- setMarkdownTheme ( themeKey === "dark" )
174-
175- if ( latestRequestedText !== text ) {
176+ const decodedCode = decodeURIComponent ( code )
177+ const success = await copyToClipboard ( decodedCode )
178+ const copyText = copyButton . querySelector ( ".copy-text" )
179+ if ( ! copyText ) {
176180 return
177181 }
178182
179- try {
180- const rendered = await renderMarkdown ( text )
181- if ( latestRequestedText === text ) {
182- const cacheEntry : RenderCache = { text, html : rendered , theme : themeKey , mode : version }
183- setHtml ( rendered )
184- cacheHandle . set ( cacheEntry )
185- notifyRendered ( )
183+ copyText . textContent = success ? t ( "markdown.codeBlock.copy.copied" ) : t ( "markdown.codeBlock.copy.failed" )
184+ setTimeout ( ( ) => {
185+ copyText . textContent = t ( "markdown.codeBlock.copy.label" )
186+ } , 2000 )
187+ }
188+
189+ containerRef ?. addEventListener ( "click" , handleClick )
190+
191+ let disposed = false
192+ void loadMarkdownModule ( )
193+ . then ( ( markdown ) => {
194+ if ( disposed ) {
195+ return
186196 }
187- } catch ( error ) {
188- log . error ( "Failed to re-render markdown after language load:" , error )
189- }
190- } )
197+
198+ cleanupLanguageListener = markdown . onLanguagesLoaded ( ( ) => {
199+ const snapshot = resolved ( )
200+ if ( ! snapshot . highlightEnabled ) {
201+ return
202+ }
203+
204+ latestRequestKey = snapshot . requestKey
205+ void renderSnapshot ( snapshot ) . catch ( ( error ) => {
206+ log . error ( "Failed to re-render markdown after language load:" , error )
207+ } )
208+ } )
209+ } )
210+ . catch ( ( error ) => {
211+ log . error ( "Failed to load markdown module:" , error )
212+ } )
191213
192214 onCleanup ( ( ) => {
215+ disposed = true
193216 containerRef ?. removeEventListener ( "click" , handleClick )
194- cleanupLanguageListener ( )
217+ cleanupLanguageListener ?.( )
218+ cleanupLanguageListener = undefined
195219 } )
196220 } )
197221
198- const proseClass = ( ) => "markdown-body"
199-
200222 return (
201223 < div
202224 ref = { containerRef }
203- class = { proseClass ( ) }
225+ class = "markdown-body"
204226 data-view = "markdown"
205227 data-part-id = { resolved ( ) . partId }
206228 data-markdown-theme = { resolved ( ) . themeKey }
0 commit comments