11"use client" ;
22
33import { useEffect , useMemo , useRef , useState } from "react" ;
4- import { marked } from "marked" ;
5- import DOMPurify from "dompurify" ;
64
75interface ArticleContentProps {
8- content : string ;
6+ htmlContent : string ;
97}
108
119type TocItem = {
@@ -15,58 +13,6 @@ type TocItem = {
1513 children : TocItem [ ] ;
1614} ;
1715
18- function getScrollParent ( el : HTMLElement ) : HTMLElement {
19- // Prefer the document scroller when appropriate.
20- const docScroller =
21- ( document . scrollingElement as HTMLElement | null ) ??
22- ( document . documentElement as HTMLElement | null ) ??
23- ( document . body as HTMLElement | null ) ;
24-
25- let parent = el . parentElement ;
26- while (
27- parent &&
28- parent !== document . body &&
29- parent !== document . documentElement
30- ) {
31- const style = window . getComputedStyle ( parent ) ;
32- const overflowY = style . overflowY ;
33- const isScrollable =
34- ( overflowY === "auto" ||
35- overflowY === "scroll" ||
36- overflowY === "overlay" ) &&
37- parent . scrollHeight > parent . clientHeight + 1 ;
38- if ( isScrollable ) return parent ;
39- parent = parent . parentElement ;
40- }
41-
42- return docScroller ?? document . documentElement ;
43- }
44-
45- function isDocumentScroller ( el : HTMLElement ) {
46- const docScroller =
47- ( document . scrollingElement as HTMLElement | null ) ??
48- ( document . documentElement as HTMLElement | null ) ??
49- ( document . body as HTMLElement | null ) ;
50- return (
51- el === docScroller ||
52- el === document . documentElement ||
53- el === document . body
54- ) ;
55- }
56-
57- function slugifyHeading ( text : string ) {
58- return text
59- . toString ( )
60- . toLowerCase ( )
61- . trim ( )
62- . replace ( / \s + / g, "-" ) // Replace spaces with -
63- . replace ( / & / g, "-and-" ) // Replace & with 'and'
64- . replace ( / [ ^ \w \- ] + / g, "" ) // Remove all non-word characters
65- . replace ( / \- \- + / g, "-" ) // Replace multiple - with single -
66- . replace ( / ^ - + / , "" ) // Trim - from start of text
67- . replace ( / - + $ / , "" ) ; // Trim - from end of text
68- }
69-
7016function buildTocTree ( flatItems : Array < Omit < TocItem , "children" > > ) : TocItem [ ] {
7117 const root : TocItem [ ] = [ ] ;
7218 const stack : TocItem [ ] = [ ] ;
@@ -90,8 +36,7 @@ function buildTocTree(flatItems: Array<Omit<TocItem, "children">>): TocItem[] {
9036 return root ;
9137}
9238
93- const ArticleContent : React . FC < ArticleContentProps > = ( { content } ) => {
94- const [ sanitizedContent , setSanitizedContent ] = useState < string > ( "" ) ;
39+ const ArticleContent : React . FC < ArticleContentProps > = ( { htmlContent } ) => {
9540 const containerRef = useRef < HTMLDivElement | null > ( null ) ;
9641 const [ tocTree , setTocTree ] = useState < TocItem [ ] > ( [ ] ) ;
9742 const [ collapsedIds , setCollapsedIds ] = useState < Set < string > > ( new Set ( ) ) ;
@@ -127,82 +72,6 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
12772 const checkSvg =
12873 '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>' ;
12974
130- useEffect ( ( ) => {
131- async function sanitizeContent ( ) {
132- // Use a custom renderer to add IDs to headings and wrap code blocks
133- const renderer = new marked . Renderer ( ) ;
134- const seenIds = new Map < string , number > ( ) ;
135-
136- renderer . heading = function ( { tokens, depth } ) {
137- const text = this . parser . parseInline ( tokens ) ;
138- const title = text . toString ( ) ;
139- // Strip HTML tags for cleaner ID generation (securely)
140- const cleanTitle = DOMPurify . sanitize ( title , { ALLOWED_TAGS : [ ] } ) ;
141- const baseId = slugifyHeading ( cleanTitle ) || "section" ;
142- const nextCount = ( seenIds . get ( baseId ) ?? 0 ) + 1 ;
143- seenIds . set ( baseId , nextCount ) ;
144- const id = nextCount === 1 ? baseId : `${ baseId } -${ nextCount } ` ;
145-
146- return `<h${ depth } id="${ id } ">${ text } </h${ depth } >` ;
147- } ;
148-
149- renderer . code = ( { text, lang } ) => {
150- const languageClass = lang ? `language-${ lang } ` : "" ;
151- // Encode text for the data attribute to be safe
152- const safeCode = text . replace ( / " / g, '"' ) ;
153-
154- return `
155- <div class="relative group my-4" data-copy-wrapper="true">
156- <button
157- type="button"
158- class="copy-btn absolute top-2 right-2 z-10 h-8 w-8 flex items-center justify-center rounded-md border border-white/20 bg-[#121212] text-white hover:bg-[#202020] transition-all"
159- aria-label="Copy code to clipboard"
160- title="Copy"
161- data-code="${ safeCode } "
162- >
163- ${ copySvg }
164- </button>
165- <pre><code class="${ languageClass } ">${ text } </code></pre>
166- </div>
167- ` ;
168- } ;
169-
170- renderer . link = ( { href, title, text } ) => {
171- return `<a href="${ href } " title="${ title || '' } " target="_blank" rel="noopener noreferrer">${ text } </a>` ;
172- } ;
173-
174- marked . use ( { renderer } ) ;
175-
176- // Convert markdown to HTML
177- const rawContent = await marked . parse ( content ) ;
178-
179- // Configure DOMPurify to allow iframes and buttons
180- const cleanContent = DOMPurify . sanitize ( rawContent , {
181- ADD_TAGS : [ "iframe" , "button" ] ,
182- ADD_ATTR : [
183- "allow" ,
184- "allowfullscreen" ,
185- "frameborder" ,
186- "height" ,
187- "scrolling" ,
188- "src" ,
189- "width" ,
190- "id" ,
191- "class" , // checking class
192- "data-code" , // Custom attribute for code content
193- "aria-label" ,
194- "title" ,
195- "type" ,
196- "target" ,
197- "rel"
198- ] ,
199- } ) ;
200-
201- setSanitizedContent ( cleanContent ) ;
202- }
203- sanitizeContent ( ) ;
204- } , [ content ] ) ;
205-
20675 useEffect ( ( ) => {
20776 if ( typeof window === "undefined" ) return ;
20877
@@ -238,15 +107,15 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
238107
239108 const level = Number ( heading . tagName . replace ( "H" , "" ) ) ;
240109 const id = heading . id ;
241-
110+
242111 if ( id ) {
243- headingMapRef . current . set ( id , heading ) ;
244- flat . push ( { id, title, level } ) ;
112+ headingMapRef . current . set ( id , heading ) ;
113+ flat . push ( { id, title, level } ) ;
245114 }
246115 }
247116
248117 setTocTree ( buildTocTree ( flat ) ) ;
249- } , [ sanitizedContent ] ) ;
118+ } , [ htmlContent ] ) ;
250119
251120 useEffect ( ( ) => {
252121 // If the page was loaded with a hash, scroll to it after headings have ids.
@@ -261,10 +130,10 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
261130 window . requestAnimationFrame ( ( ) => {
262131 const el = document . getElementById ( id ) ;
263132 if ( el ) {
264- const headerOffset = 120 ;
265- const elementPosition = el . getBoundingClientRect ( ) . top + window . scrollY ;
266- const offsetPosition = elementPosition - headerOffset ;
267- window . scrollTo ( { top : offsetPosition , behavior : "smooth" } ) ;
133+ const headerOffset = 120 ;
134+ const elementPosition = el . getBoundingClientRect ( ) . top + window . scrollY ;
135+ const offsetPosition = elementPosition - headerOffset ;
136+ window . scrollTo ( { top : offsetPosition , behavior : "smooth" } ) ;
268137 }
269138 } ) ;
270139 } , [ tocTree ] ) ;
@@ -277,23 +146,26 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
277146 const handleCopyClick = async ( e : MouseEvent ) => {
278147 const target = e . target as HTMLElement ;
279148 const button = target . closest ( ".copy-btn" ) as HTMLButtonElement ;
280-
149+
281150 if ( ! button || ! root . contains ( button ) ) return ;
282151
283152 e . preventDefault ( ) ;
284153 e . stopPropagation ( ) ;
285154
286155 const code = button . getAttribute ( "data-code" ) ;
287156 // Fallback to finding the code block if attribute is empty/missing
288- const codeText = code || button . nextElementSibling ?. querySelector ( "code" ) ?. textContent || "" ;
157+ const codeText =
158+ code ||
159+ button . nextElementSibling ?. querySelector ( "code" ) ?. textContent ||
160+ "" ;
289161
290162 if ( ! codeText ) return ;
291163
292164 try {
293165 await copyTextToClipboard ( codeText ) ;
294166 button . innerHTML = checkSvg ;
295167 button . setAttribute ( "title" , "Copied" ) ;
296-
168+
297169 setTimeout ( ( ) => {
298170 button . innerHTML = copySvg ;
299171 button . setAttribute ( "title" , "Copy" ) ;
@@ -308,7 +180,7 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
308180 return ( ) => {
309181 root . removeEventListener ( "click" , handleCopyClick ) ;
310182 } ;
311- } , [ sanitizedContent ] ) ;
183+ } , [ htmlContent ] ) ;
312184
313185 const handleTocToggle = ( id : string ) => {
314186 setCollapsedIds ( ( prev ) => {
@@ -332,15 +204,14 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
332204
333205 return (
334206 < article className = "grid md:grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] xl:grid-cols-[minmax(0,1fr)_380px] gap-0 md:gap-8 lg:gap-12 w-full max-w-[90rem] mx-auto mt-6 md:mt-24 mb-10 px-4" >
335-
336- < MobileToc
337- items = { tocTree }
338- collapsedIds = { collapsedIds }
339- onToggle = { handleTocToggle }
207+ < MobileToc
208+ items = { tocTree }
209+ collapsedIds = { collapsedIds }
210+ onToggle = { handleTocToggle }
340211 />
341212
342213 < div className = "markdown-body w-full min-w-0" ref = { containerRef } >
343- < div dangerouslySetInnerHTML = { { __html : sanitizedContent } } />
214+ < div dangerouslySetInnerHTML = { { __html : htmlContent } } />
344215 </ div >
345216
346217 { tocTree . length > 0 ? (
@@ -388,7 +259,11 @@ function MobileToc({
388259
389260 { isOpen && (
390261 < nav className = "mt-4 max-h-96 overflow-auto" >
391- < TocList items = { items } collapsedIds = { collapsedIds } onToggle = { onToggle } />
262+ < TocList
263+ items = { items }
264+ collapsedIds = { collapsedIds }
265+ onToggle = { onToggle }
266+ />
392267 </ nav >
393268 ) }
394269 </ div >
0 commit comments