Skip to content

Commit edbf84c

Browse files
committed
Fix table padding width calculation
1 parent 117eb7f commit edbf84c

File tree

1 file changed

+142
-48
lines changed

1 file changed

+142
-48
lines changed

cli/src/utils/markdown-renderer.tsx

Lines changed: 142 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { TextAttributes } from '@opentui/core'
22
import React from 'react'
3+
import remarkBreaks from 'remark-breaks'
4+
import remarkGfm from 'remark-gfm'
35
import remarkParse from 'remark-parse'
46
import { 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'
2328
import 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

103111
type 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

157168
const 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

173175
const 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+
364370
const 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+
636712
const 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

Comments
 (0)