@@ -12,18 +12,104 @@ import {
1212} from 'react'
1313import { useVirtualizer } from '@tanstack/react-virtual'
1414import { ChevronRight } from 'lucide-react'
15- import { highlight , languages } from 'prismjs'
16- import 'prismjs/components/prism-javascript'
17- import 'prismjs/components/prism-python'
18- import 'prismjs/components/prism-json'
1915import { cn } from '@/lib/core/utils/cn'
2016import './code.css'
2117
2218/**
23- * Re-export Prism.js highlighting utilities for use across the app.
24- * Components can import these instead of importing from prismjs directly .
19+ * Shape of the lazily-loaded Prism module (`./prism`), narrowed to the two
20+ * members this component uses for highlighting .
2521 */
26- export { highlight , languages }
22+ type PrismModule = typeof import ( './prism' )
23+
24+ /**
25+ * Module-level singleton promise for the lazily-loaded Prism module.
26+ *
27+ * Prism (core + the side-effectful JS/Python/JSON grammar registrations) is kept
28+ * out of this module's static import graph so it never lands in bundles that only
29+ * pull `Code` through the shared `@/components/emcn` barrel. It is loaded once per
30+ * session on the first highlight and cached here for all subsequent viewers.
31+ */
32+ let prismModulePromise : Promise < PrismModule > | null = null
33+
34+ /**
35+ * The resolved Prism module, cached synchronously once the first load settles so
36+ * later viewers can initialize from it without a null→loaded render cycle.
37+ */
38+ let resolvedPrism : PrismModule | null = null
39+
40+ /**
41+ * Loads the Prism module once and caches both the in-flight promise and the
42+ * resolved module for reuse.
43+ *
44+ * @returns A promise resolving to the Prism highlighting utilities.
45+ */
46+ function loadPrism ( ) : Promise < PrismModule > {
47+ if ( ! prismModulePromise ) {
48+ prismModulePromise = import ( './prism' ) . then ( ( mod ) => {
49+ resolvedPrism = mod
50+ return mod
51+ } )
52+ }
53+ return prismModulePromise
54+ }
55+
56+ /**
57+ * Subscribes a client component to the lazily-loaded Prism module.
58+ *
59+ * Seeds from {@link resolvedPrism} so viewers mounted after the first load
60+ * highlight synchronously; otherwise returns `null` until Prism resolves (so
61+ * callers render the plaintext fallback), then the loaded module.
62+ *
63+ * @returns The loaded Prism module, or `null` while loading.
64+ */
65+ function usePrism ( ) : PrismModule | null {
66+ const [ prism , setPrism ] = useState < PrismModule | null > ( resolvedPrism )
67+
68+ useEffect ( ( ) => {
69+ if ( prism ) return
70+ let active = true
71+ loadPrism ( ) . then ( ( mod ) => {
72+ if ( active ) setPrism ( mod )
73+ } )
74+ return ( ) => {
75+ active = false
76+ }
77+ } , [ prism ] )
78+
79+ return prism
80+ }
81+
82+ /**
83+ * Escapes HTML special characters so raw code can be safely injected via
84+ * `dangerouslySetInnerHTML` as the plaintext fallback before Prism loads (and
85+ * for unknown languages). Matches Prism's own escaping for visual parity.
86+ *
87+ * @param text - The raw code text to escape
88+ * @returns The HTML-escaped text
89+ */
90+ function escapeHtml ( text : string ) : string {
91+ return text
92+ . replace ( / & / g, '&' )
93+ . replace ( / < / g, '<' )
94+ . replace ( / > / g, '>' )
95+ . replace ( / " / g, '"' )
96+ . replace ( / ' / g, ''' )
97+ }
98+
99+ /**
100+ * Highlights a single line of code, falling back to escaped plaintext when Prism
101+ * has not loaded yet or the language grammar is unavailable.
102+ *
103+ * @param prism - The loaded Prism module, or `null` while loading
104+ * @param text - The line of code to highlight
105+ * @param language - The language key (e.g. `json`, `javascript`, `python`)
106+ * @returns Highlighted HTML, or escaped plaintext as a fallback
107+ */
108+ function highlightOrEscape ( prism : PrismModule | null , text : string , language : string ) : string {
109+ if ( ! prism ) return escapeHtml ( text )
110+ const grammar = prism . languages [ language ] || prism . languages . javascript
111+ return prism . highlight ( text , grammar , language )
112+ }
27113
28114/**
29115 * Code editor configuration and constants.
@@ -844,6 +930,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
844930 const containerRef = useRef < HTMLDivElement > ( null )
845931 const scrollRef = useRef < HTMLDivElement > ( null )
846932 const [ containerHeight , setContainerHeight ] = useState ( 400 )
933+ const prism = usePrism ( )
847934
848935 const lines = useMemo ( ( ) => code . split ( '\n' ) , [ code ] )
849936 const gutterWidth = useMemo ( ( ) => calculateGutterWidth ( lines . length ) , [ lines . length ] )
@@ -890,11 +977,10 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
890977
891978 // Only process visible lines for efficiency (not all lines)
892979 const visibleLines = useMemo ( ( ) => {
893- const lang = languages [ language ] || languages . javascript
894980 const hasSearch = searchQuery ?. trim ( )
895981
896982 return visibleLineIndices . map ( ( idx ) => {
897- let html = highlight ( displayLines [ idx ] , lang , language )
983+ let html = highlightOrEscape ( prism , displayLines [ idx ] , language )
898984
899985 if ( hasSearch && searchQuery ) {
900986 const result = applySearchHighlightingToLine (
@@ -908,7 +994,15 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
908994
909995 return { lineNumber : idx + 1 , html }
910996 } )
911- } , [ displayLines , language , visibleLineIndices , searchQuery , currentMatchIndex , matchOffsets ] )
997+ } , [
998+ prism ,
999+ displayLines ,
1000+ language ,
1001+ visibleLineIndices ,
1002+ searchQuery ,
1003+ currentMatchIndex ,
1004+ matchOffsets ,
1005+ ] )
9121006
9131007 const hasCollapsibleContent = collapsibleLines . size > 0
9141008 const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
@@ -1037,6 +1131,7 @@ const ViewerInner = memo(function ViewerInner({
10371131 contentRef,
10381132 showCollapseColumn,
10391133} : ViewerInnerProps ) {
1134+ const prism = usePrism ( )
10401135 const lines = useMemo ( ( ) => code . split ( '\n' ) , [ code ] )
10411136 const gutterWidth = useMemo ( ( ) => calculateGutterWidth ( lines . length ) , [ lines . length ] )
10421137
@@ -1083,22 +1178,21 @@ const ViewerInner = memo(function ViewerInner({
10831178
10841179 // Pre-compute highlighted lines with search for visible indices (for gutter mode)
10851180 const highlightedVisibleLines = useMemo ( ( ) => {
1086- const lang = languages [ language ] || languages . javascript
1087-
10881181 if ( ! searchQuery ?. trim ( ) ) {
10891182 return visibleLineIndices . map ( ( idx ) => ( {
10901183 lineNumber : idx + 1 ,
1091- html : highlight ( displayLines [ idx ] , lang , language ) || ' ' ,
1184+ html : highlightOrEscape ( prism , displayLines [ idx ] , language ) || ' ' ,
10921185 } ) )
10931186 }
10941187
10951188 return visibleLineIndices . map ( ( idx ) => {
1096- let html = highlight ( displayLines [ idx ] , lang , language )
1189+ let html = highlightOrEscape ( prism , displayLines [ idx ] , language )
10971190 const matchCounter = { count : cumulativeMatches [ idx ] }
10981191 html = applySearchHighlighting ( html , searchQuery , currentMatchIndex , matchCounter )
10991192 return { lineNumber : idx + 1 , html : html || ' ' }
11001193 } )
11011194 } , [
1195+ prism ,
11021196 displayLines ,
11031197 language ,
11041198 visibleLineIndices ,
@@ -1109,16 +1203,15 @@ const ViewerInner = memo(function ViewerInner({
11091203
11101204 // Pre-compute simple highlighted code (for no-gutter mode)
11111205 const highlightedCode = useMemo ( ( ) => {
1112- const lang = languages [ language ] || languages . javascript
11131206 const visibleCode = visibleLineIndices . map ( ( idx ) => displayLines [ idx ] ) . join ( '\n' )
1114- let html = highlight ( visibleCode , lang , language )
1207+ let html = highlightOrEscape ( prism , visibleCode , language )
11151208
11161209 if ( searchQuery ?. trim ( ) ) {
11171210 const matchCounter = { count : 0 }
11181211 html = applySearchHighlighting ( html , searchQuery , currentMatchIndex , matchCounter )
11191212 }
11201213 return html
1121- } , [ displayLines , language , visibleLineIndices , searchQuery , currentMatchIndex ] )
1214+ } , [ prism , displayLines , language , visibleLineIndices , searchQuery , currentMatchIndex ] )
11221215
11231216 const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
11241217
0 commit comments