From 9baeab48894271454c2b99338bf2ffbc0d98e2ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 1 Apr 2026 20:56:37 +0400 Subject: [PATCH] Add table conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- pkg/prosemirror/markdown.go | 93 +++++++++++++++++++++++++++++++- pkg/prosemirror/markdown_test.go | 74 +++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/pkg/prosemirror/markdown.go b/pkg/prosemirror/markdown.go index e13ce1511..d509b727b 100644 --- a/pkg/prosemirror/markdown.go +++ b/pkg/prosemirror/markdown.go @@ -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 diff --git a/pkg/prosemirror/markdown_test.go b/pkg/prosemirror/markdown_test.go index 53030b78e..90ec4ba8d 100644 --- a/pkg/prosemirror/markdown_test.go +++ b/pkg/prosemirror/markdown_test.go @@ -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()