11import { TextAttributes } from '@opentui/core'
22import React from 'react'
3+ import remarkBreaks from 'remark-breaks'
4+ import remarkGfm from 'remark-gfm'
35import remarkParse from 'remark-parse'
46import { unified } from 'unified'
57
@@ -18,6 +20,9 @@ import type {
1820 Paragraph ,
1921 Root ,
2022 Strong ,
23+ Table ,
24+ TableCell ,
25+ TableRow ,
2126 Text ,
2227} from 'mdast'
2328import type { ReactNode } from 'react'
@@ -98,7 +103,10 @@ const resolvePalette = (
98103 return palette
99104}
100105
101- const processor = unified ( ) . use ( remarkParse )
106+ const processor = unified ( )
107+ . use ( remarkParse )
108+ . use ( remarkGfm )
109+ . use ( remarkBreaks )
102110
103111type MarkdownNode = Content | Root
104112
@@ -138,36 +146,30 @@ const flattenChildren = (lists: ReactNode[][]): ReactNode[] => {
138146 return flattened
139147}
140148
141- const trimTrailingWhitespaceNodes = ( nodes : ReactNode [ ] ) : ReactNode [ ] => {
149+ // Unified trim helper with predicate
150+ const trimTrailingNodes = (
151+ nodes : ReactNode [ ] ,
152+ predicate : ( node : ReactNode ) => boolean ,
153+ ) : ReactNode [ ] => {
142154 let end = nodes . length
143- while ( end > 0 ) {
144- const value = nodes [ end - 1 ]
145- if ( typeof value === 'string' && value . trim ( ) . length === 0 ) {
146- end -= 1
147- continue
148- }
149- break
155+ while ( end > 0 && predicate ( nodes [ end - 1 ] ) ) {
156+ end -= 1
150157 }
151- if ( end === nodes . length ) {
152- return nodes
153- }
154- return nodes . slice ( 0 , end )
158+ return end === nodes . length ? nodes : nodes . slice ( 0 , end )
159+ }
160+
161+ const trimTrailingWhitespaceNodes = ( nodes : ReactNode [ ] ) : ReactNode [ ] => {
162+ return trimTrailingNodes (
163+ nodes ,
164+ ( node ) => typeof node === 'string' && node . trim ( ) . length === 0 ,
165+ )
155166}
156167
157168const trimTrailingBreaks = ( nodes : ReactNode [ ] ) : ReactNode [ ] => {
158- let end = nodes . length
159- while ( end > 0 ) {
160- const value = nodes [ end - 1 ]
161- if ( typeof value === 'string' && ( value === '\n' || value === '\n\n' ) ) {
162- end -= 1
163- continue
164- }
165- break
166- }
167- if ( end === nodes . length ) {
168- return nodes
169- }
170- return nodes . slice ( 0 , end )
169+ return trimTrailingNodes (
170+ nodes ,
171+ ( node ) => typeof node === 'string' && / ^ \n + $ / . test ( node ) ,
172+ )
171173}
172174
173175const splitNodesByNewline = ( nodes : ReactNode [ ] ) : ReactNode [ ] [ ] => {
@@ -361,32 +363,34 @@ const applyInlineFallbackFormatting = (node: MarkdownNode): void => {
361363 mutable . children = nextChildren
362364}
363365
366+ const getChildrenText = ( children : MarkdownNode [ ] ) : string => {
367+ return children . map ( nodeToPlainText ) . join ( '' )
368+ }
369+
364370const nodeToPlainText = ( node : MarkdownNode ) : string => {
365371 switch ( node . type ) {
366372 case 'root' :
367- return ( node as Root ) . children . map ( nodeToPlainText ) . join ( '' )
373+ return getChildrenText ( ( node as Root ) . children as MarkdownNode [ ] )
368374
369375 case 'paragraph' :
370- return (
371- ( node as Paragraph ) . children . map ( nodeToPlainText ) . join ( '' ) + '\n\n'
372- )
376+ return getChildrenText ( ( node as Paragraph ) . children as MarkdownNode [ ] ) + '\n\n'
373377
374378 case 'text' :
375379 return ( node as Text ) . value
376380
377381 case 'strong' :
378- return ( node as Strong ) . children . map ( nodeToPlainText ) . join ( '' )
382+ return getChildrenText ( ( node as Strong ) . children as MarkdownNode [ ] )
379383
380384 case 'emphasis' :
381- return ( node as Emphasis ) . children . map ( nodeToPlainText ) . join ( '' )
385+ return getChildrenText ( ( node as Emphasis ) . children as MarkdownNode [ ] )
382386
383387 case 'inlineCode' :
384388 return ( node as InlineCode ) . value
385389
386390 case 'heading' : {
387391 const heading = node as Heading
388392 const prefix = '#' . repeat ( Math . max ( 1 , Math . min ( heading . depth , 6 ) ) )
389- const content = heading . children . map ( nodeToPlainText ) . join ( '' )
393+ const content = getChildrenText ( heading . children as MarkdownNode [ ] )
390394 return `${ prefix } ${ content } \n\n`
391395 }
392396
@@ -396,20 +400,15 @@ const nodeToPlainText = (node: MarkdownNode): string => {
396400 list . children
397401 . map ( ( item , idx ) => {
398402 const marker = list . ordered ? `${ ( list . start ?? 1 ) + idx } . ` : '- '
399- const text = ( item as ListItem ) . children
400- . map ( nodeToPlainText )
401- . join ( '' )
402- . trimEnd ( )
403+ const text = getChildrenText ( ( item as ListItem ) . children as MarkdownNode [ ] ) . trimEnd ( )
403404 return marker + text
404405 } )
405406 . join ( '\n' ) + '\n\n'
406407 )
407408 }
408409
409- case 'listItem' : {
410- const listItem = node as ListItem
411- return listItem . children . map ( nodeToPlainText ) . join ( '' )
412- }
410+ case 'listItem' :
411+ return getChildrenText ( ( node as ListItem ) . children as MarkdownNode [ ] )
413412
414413 case 'blockquote' : {
415414 const blockquote = node as Blockquote
@@ -433,19 +432,41 @@ const nodeToPlainText = (node: MarkdownNode): string => {
433432
434433 case 'link' : {
435434 const link = node as Link
436- const label =
437- link . children . length > 0
438- ? link . children . map ( nodeToPlainText ) . join ( '' )
439- : link . url
435+ const label = link . children . length > 0
436+ ? getChildrenText ( link . children as MarkdownNode [ ] )
437+ : link . url
440438 return label
441439 }
442440
441+ case 'table' : {
442+ const table = node as Table
443+ return table . children
444+ . map ( ( row ) => {
445+ const cells = ( row as TableRow ) . children as TableCell [ ]
446+ return cells . map ( ( cell ) => nodeToPlainText ( cell ) ) . join ( ' | ' )
447+ } )
448+ . join ( '\n' ) + '\n\n'
449+ }
450+
451+ case 'tableRow' :
452+ return ( node as TableRow ) . children . map ( nodeToPlainText ) . join ( ' | ' )
453+
454+ case 'tableCell' :
455+ return getChildrenText ( ( node as TableCell ) . children as MarkdownNode [ ] )
456+
457+ case 'delete' : {
458+ // Strikethrough - just return the text content
459+ const deleteNode = node as any
460+ if ( Array . isArray ( deleteNode . children ) ) {
461+ return getChildrenText ( deleteNode . children as MarkdownNode [ ] )
462+ }
463+ return ''
464+ }
465+
443466 default : {
444467 const anyNode = node as any
445468 if ( Array . isArray ( anyNode . children ) ) {
446- return ( anyNode . children as MarkdownNode [ ] )
447- . map ( nodeToPlainText )
448- . join ( '' )
469+ return getChildrenText ( anyNode . children as MarkdownNode [ ] )
449470 }
450471 return ''
451472 }
@@ -633,6 +654,61 @@ const renderLink = (link: Link, state: RenderState): ReactNode[] => {
633654 ]
634655}
635656
657+ const renderTable = ( table : Table , state : RenderState ) : ReactNode [ ] => {
658+ const { palette, nextKey } = state
659+ const nodes : ReactNode [ ] = [ ]
660+
661+ // Calculate column widths
662+ const columnWidths : number [ ] = [ ]
663+ table . children . forEach ( ( row ) => {
664+ ( row as TableRow ) . children . forEach ( ( cell , colIdx ) => {
665+ const cellText = nodeToPlainText ( cell as TableCell )
666+ const width = cellText . length
667+ columnWidths [ colIdx ] = Math . max ( columnWidths [ colIdx ] || 0 , width )
668+ } )
669+ } )
670+
671+ // Render each row
672+ table . children . forEach ( ( row , rowIdx ) => {
673+ const isHeader = rowIdx === 0
674+ const cells = ( row as TableRow ) . children as TableCell [ ]
675+
676+ // Render cells in the row
677+ cells . forEach ( ( cell , cellIdx ) => {
678+ const cellNodes = renderNodes (
679+ cell . children as MarkdownNode [ ] ,
680+ state ,
681+ cell . type ,
682+ )
683+ const cellWidth = columnWidths [ cellIdx ] || 10
684+ const cellText = nodeToPlainText ( cell )
685+ const padding = ' ' . repeat ( Math . max ( 0 , cellWidth - cellText . length ) )
686+
687+ nodes . push (
688+ < span key = { nextKey ( ) } fg = { isHeader ? palette . headingFg [ 3 ] : undefined } >
689+ { cellIdx === 0 ? '| ' : ' | ' }
690+ { wrapSegmentsInFragments ( cellNodes , nextKey ( ) ) }
691+ { padding }
692+ </ span > ,
693+ )
694+ } )
695+ nodes . push ( ' |\n' )
696+
697+ // Add separator line after header
698+ if ( isHeader ) {
699+ nodes . push ( '|' )
700+ columnWidths . forEach ( ( width , idx ) => {
701+ nodes . push ( idx === 0 ? ' ' : ' | ' )
702+ nodes . push ( '-' . repeat ( width ) )
703+ } )
704+ nodes . push ( ' |\n' )
705+ }
706+ } )
707+
708+ nodes . push ( '\n' )
709+ return nodes
710+ }
711+
636712const renderNode = (
637713 node : MarkdownNode ,
638714 state : RenderState ,
@@ -737,6 +813,24 @@ const renderNode = (
737813 case 'link' :
738814 return renderLink ( node as Link , state )
739815
816+ case 'table' :
817+ return renderTable ( node as Table , state )
818+
819+ case 'delete' : {
820+ // Strikethrough from GFM
821+ const anyNode = node as any
822+ const children = renderNodes (
823+ anyNode . children as MarkdownNode [ ] ,
824+ state ,
825+ node . type ,
826+ )
827+ return [
828+ < span key = { state . nextKey ( ) } attributes = { TextAttributes . DIM } >
829+ { wrapSegmentsInFragments ( children , state . nextKey ( ) ) }
830+ </ span > ,
831+ ]
832+ }
833+
740834 default : {
741835 const fallbackText = nodeToPlainText ( node )
742836 if ( fallbackText ) {
0 commit comments