Add table conversion

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-01 20:56:37 +04:00
parent e8789533a7
commit 9baeab4889
2 changed files with 165 additions and 2 deletions

View File

@@ -33,7 +33,10 @@ func ParseMarkdown(markdown string) (Node, error) {
source := []byte(markdown)
md := goldmark.New(
goldmark.WithExtensions(goldmarkext.Strikethrough),
goldmark.WithExtensions(
goldmarkext.Strikethrough,
goldmarkext.Table,
),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
)
@@ -169,8 +172,17 @@ func (c *converter) convertNode(n ast.Node) ([]Node, error) {
case ast.KindHTMLBlock:
return c.convertHTMLBlock(n.(*ast.HTMLBlock))
default:
if n.Kind() == goldmarkast.KindStrikethrough {
switch n.Kind() {
case goldmarkast.KindStrikethrough:
return c.convertStrikethrough(n)
case goldmarkast.KindTable:
return c.convertTable(n)
case goldmarkast.KindTableHeader:
return c.convertTableHeaderRow(n.(*goldmarkast.TableHeader))
case goldmarkast.KindTableRow:
return c.convertTableDataRow(n)
case goldmarkast.KindTableCell:
return nil, fmt.Errorf("cannot convert table cell outside of a table row")
}
return nil, fmt.Errorf("cannot convert markdown node of kind %s", n.Kind())
}
@@ -516,6 +528,83 @@ func (c *converter) convertStrikethrough(n ast.Node) ([]Node, error) {
return children, nil
}
func (c *converter) convertTable(n ast.Node) ([]Node, error) {
var rows []Node
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
converted, err := c.convertNode(child)
if err != nil {
return nil, err
}
rows = append(rows, converted...)
}
if len(rows) == 0 {
return nil, nil
}
return []Node{{Type: NodeTable, Content: rows}}, nil
}
func (c *converter) convertTableHeaderRow(n *goldmarkast.TableHeader) ([]Node, error) {
cells, err := c.convertTableCells(n, NodeTableHeader)
if err != nil {
return nil, err
}
if len(cells) == 0 {
return nil, nil
}
return []Node{{Type: NodeTableRow, Content: cells}}, nil
}
func (c *converter) convertTableDataRow(n ast.Node) ([]Node, error) {
cells, err := c.convertTableCells(n, NodeTableCell)
if err != nil {
return nil, err
}
if len(cells) == 0 {
return nil, nil
}
return []Node{{Type: NodeTableRow, Content: cells}}, nil
}
func (c *converter) convertTableCells(row ast.Node, cellType NodeType) ([]Node, error) {
var cells []Node
for child := row.FirstChild(); child != nil; child = child.NextSibling() {
if child.Kind() != goldmarkast.KindTableCell {
continue
}
inlineContent, err := c.convertInlineChildren(child)
if err != nil {
return nil, err
}
cellAttrs := TableCellAttrs{
Colspan: 1,
Rowspan: 1,
}
attrs, err := json.Marshal(cellAttrs)
if err != nil {
return nil, fmt.Errorf("cannot marshal table cell attrs: %w", err)
}
cells = append(cells, Node{
Type: cellType,
Attrs: attrs,
Content: []Node{{Type: NodeParagraph, Content: inlineContent}},
})
}
return cells, nil
}
// extractText recursively collects the text content of all descendant nodes.
func (c *converter) extractText(n ast.Node) string {
var buf bytes.Buffer

View File

@@ -570,6 +570,80 @@ func TestParseMarkdown_BlockHTMLTableCellSpans(t *testing.T) {
assert.Equal(t, 2, attrs.Rowspan)
}
func TestParseMarkdown_MarkdownTable(t *testing.T) {
t.Parallel()
md := "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |\n"
doc, err := ParseMarkdown(md)
require.NoError(t, err)
require.Len(t, doc.Content, 1)
assert.Equal(t, NodeTable, doc.Content[0].Type)
table := doc.Content[0]
require.Len(t, table.Content, 3)
headerRow := table.Content[0]
assert.Equal(t, NodeTableRow, headerRow.Type)
require.Len(t, headerRow.Content, 2)
assert.Equal(t, NodeTableHeader, headerRow.Content[0].Type)
assert.Equal(t, NodeTableHeader, headerRow.Content[1].Type)
require.Len(t, headerRow.Content[0].Content, 1)
require.Equal(t, NodeParagraph, headerRow.Content[0].Content[0].Type)
require.Len(t, headerRow.Content[0].Content[0].Content, 1)
assert.Equal(t, "Name", *headerRow.Content[0].Content[0].Content[0].Text)
require.Len(t, headerRow.Content[1].Content, 1)
require.Equal(t, NodeParagraph, headerRow.Content[1].Content[0].Type)
require.Len(t, headerRow.Content[1].Content[0].Content, 1)
assert.Equal(t, "Age", *headerRow.Content[1].Content[0].Content[0].Text)
for i := 1; i <= 2; i++ {
row := table.Content[i]
assert.Equal(t, NodeTableRow, row.Type)
require.Len(t, row.Content, 2)
assert.Equal(t, NodeTableCell, row.Content[0].Type)
assert.Equal(t, NodeTableCell, row.Content[1].Type)
attrs, err := row.Content[0].TableCellAttrs()
require.NoError(t, err)
assert.Equal(t, 1, attrs.Colspan)
assert.Equal(t, 1, attrs.Rowspan)
}
assert.Equal(t, "Alice", *table.Content[1].Content[0].Content[0].Content[0].Text)
assert.Equal(t, "30", *table.Content[1].Content[1].Content[0].Content[0].Text)
assert.Equal(t, "Bob", *table.Content[2].Content[0].Content[0].Content[0].Text)
assert.Equal(t, "25", *table.Content[2].Content[1].Content[0].Content[0].Text)
}
func TestParseMarkdown_MarkdownTableWithInlineMarks(t *testing.T) {
t.Parallel()
md := "| Header |\n| --- |\n| **bold** and *italic* |\n"
doc, err := ParseMarkdown(md)
require.NoError(t, err)
require.Len(t, doc.Content, 1)
assert.Equal(t, NodeTable, doc.Content[0].Type)
dataRow := doc.Content[0].Content[1]
assert.Equal(t, NodeTableRow, dataRow.Type)
cell := dataRow.Content[0]
assert.Equal(t, NodeTableCell, cell.Type)
p := cell.Content[0]
require.Equal(t, NodeParagraph, p.Type)
require.GreaterOrEqual(t, len(p.Content), 3)
assert.Equal(t, "bold", *p.Content[0].Text)
require.Len(t, p.Content[0].Marks, 1)
assert.Equal(t, MarkStrong, p.Content[0].Marks[0].Type)
assert.Equal(t, "italic", *p.Content[2].Text)
require.Len(t, p.Content[2].Marks, 1)
assert.Equal(t, MarkEm, p.Content[2].Marks[0].Type)
}
func TestParseMarkdown_BlockHTMLScriptRemovedKeepsSafeContent(t *testing.T) {
t.Parallel()