diff --git a/blocks.go b/blocks.go index 8b9c25d..e058fd3 100644 --- a/blocks.go +++ b/blocks.go @@ -152,16 +152,16 @@ func (ctx *renderContext) handleParagraph(n *ast.Paragraph, entering bool) { frame.elements = append(frame.elements, slack.NewRichTextSectionTextElement("\n", nil)) } - frame.elements = append(frame.elements, ctx.inlineElements...) + resolved := resolveEmojis(ctx.inlineElements) + frame.elements = append(frame.elements, resolved...) ctx.inlineElements = nil return } - // Normal paragraph → section block with mrkdwn. + // Normal paragraph → rich text block. if len(ctx.inlineElements) > 0 { - sec := slack.NewRichTextSection(ctx.inlineElements...) + sec := ctx.flushInlineToSection() ctx.emitBlock(slack.NewRichTextBlock("", sec)) - ctx.inlineElements = nil } } } diff --git a/context.go b/context.go index 4ec2d53..78a1188 100644 --- a/context.go +++ b/context.go @@ -1,11 +1,29 @@ package md2slack import ( + "regexp" "strings" "github.com/slack-go/slack" ) +// emojiShortcodeRe matches potential Slack emoji shortcodes like :bar_chart:, :+1:, :wave:. +// Slack emoji names start with a letter, digit, or plus sign, followed by letters, digits, +// underscores, hyphens, or plus signs. This regex intentionally over-matches (e.g. pure-digit +// names like :49:); the companion function isValidEmojiName filters out false positives. +var emojiShortcodeRe = regexp.MustCompile(`:([a-z0-9+][a-z0-9_+\-]*):`) + +// isValidEmojiName returns true if the name contains at least one ASCII letter (a-z) or +// a plus sign. Pure-digit names like "49" from time strings (19:49:41) are not valid emojis. +func isValidEmojiName(name string) bool { + for _, c := range name { + if (c >= 'a' && c <= 'z') || c == '+' { + return true + } + } + return false +} + // renderContext tracks all state during the AST walk. type renderContext struct { source []byte @@ -139,16 +157,112 @@ func (ctx *renderContext) addLink(url, text string) { } // flushInlineToSection wraps current inline elements in a RichTextSection and returns it. -// Clears the inline accumulator. +// Clears the inline accumulator. Emoji shortcodes are resolved before flushing. func (ctx *renderContext) flushInlineToSection() *slack.RichTextSection { if len(ctx.inlineElements) == 0 { return nil } - sec := slack.NewRichTextSection(ctx.inlineElements...) + resolved := resolveEmojis(ctx.inlineElements) + sec := slack.NewRichTextSection(resolved...) ctx.inlineElements = nil return sec } +// stylesEqual returns true if two text styles are equivalent. +func stylesEqual(a, b *slack.RichTextSectionTextStyle) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Bold == b.Bold && a.Italic == b.Italic && a.Strike == b.Strike && a.Code == b.Code +} + +// resolveEmojis post-processes a slice of RichTextSectionElements to find and convert +// emoji shortcodes (e.g. :bar_chart:) into RichTextSectionEmojiElement objects. +// +// Goldmark's emphasis parser can split text at underscores, fragmenting emoji shortcodes +// like ":bar_chart:" into separate text elements (":bar_" and "chart:"). This function +// first merges adjacent text elements with the same style, then scans the merged text +// for emoji shortcodes and emits proper emoji elements. +func resolveEmojis(elements []slack.RichTextSectionElement) []slack.RichTextSectionElement { + if len(elements) == 0 { + return elements + } + + // Step 1: Merge adjacent text elements with the same style. + // This reassembles text that goldmark split at underscore boundaries. + merged := mergeAdjacentText(elements) + + // Step 2: Scan merged text elements for emoji shortcodes and split them + // into text + emoji elements. + var result []slack.RichTextSectionElement + for _, elem := range merged { + te, ok := elem.(*slack.RichTextSectionTextElement) + if !ok || (te.Style != nil && te.Style.Code) { + // Non-text elements and code-styled text are passed through as-is. + // Inline code (backticks) should render :fire: literally, not as an emoji. + result = append(result, elem) + continue + } + + matches := emojiShortcodeRe.FindAllStringIndex(te.Text, -1) + if matches == nil { + result = append(result, elem) + continue + } + + cursor := 0 + for _, loc := range matches { + name := te.Text[loc[0]+1 : loc[1]-1] + if !isValidEmojiName(name) { + // Not a valid emoji (e.g. pure digits from time strings like 19:49:41). + // Leave the text as-is and skip this match. + continue + } + if loc[0] > cursor { + result = append(result, slack.NewRichTextSectionTextElement(te.Text[cursor:loc[0]], te.Style)) + } + result = append(result, slack.NewRichTextSectionEmojiElement(name, 0, te.Style)) + cursor = loc[1] + } + if cursor < len(te.Text) { + result = append(result, slack.NewRichTextSectionTextElement(te.Text[cursor:], te.Style)) + } + } + + return result +} + +// mergeAdjacentText combines consecutive RichTextSectionTextElement entries +// that share the same style into single elements. This is necessary because +// goldmark's emphasis parser splits text at underscore boundaries, which +// fragments emoji shortcodes like ":bar_chart:" across multiple elements. +func mergeAdjacentText(elements []slack.RichTextSectionElement) []slack.RichTextSectionElement { + if len(elements) <= 1 { + return elements + } + + result := make([]slack.RichTextSectionElement, 0, len(elements)) + for _, elem := range elements { + te, ok := elem.(*slack.RichTextSectionTextElement) + if !ok || len(result) == 0 { + result = append(result, elem) + continue + } + + prev, prevOk := result[len(result)-1].(*slack.RichTextSectionTextElement) + if prevOk && stylesEqual(prev.Style, te.Style) { + // Merge into the previous element. + result[len(result)-1] = slack.NewRichTextSectionTextElement(prev.Text+te.Text, prev.Style) + } else { + result = append(result, elem) + } + } + return result +} + // emitBlock appends a block to the output. func (ctx *renderContext) emitBlock(b slack.Block) { if b == nil { diff --git a/renderer_test.go b/renderer_test.go index 424f550..6a56866 100644 --- a/renderer_test.go +++ b/renderer_test.go @@ -1493,6 +1493,461 @@ func TestConvert_TableCodeInCell(t *testing.T) { } } +func TestConvert_EmojiShortcodes(t *testing.T) { + tests := []struct { + name string + input string + check func(t *testing.T, blocks []slack.Block) + }{ + { + name: "single emoji becomes emoji element", + input: ":bar_chart:", + check: func(t *testing.T, blocks []slack.Block) { + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d: %s", len(blocks), blockJSON(t, blocks)) + } + rt := blocks[0].(*slack.RichTextBlock) + sec := rt.Elements[0].(*slack.RichTextSection) + if len(sec.Elements) != 1 { + t.Fatalf("expected 1 element, got %d: %s", len(sec.Elements), blockJSON(t, blocks)) + } + emoji, ok := sec.Elements[0].(*slack.RichTextSectionEmojiElement) + if !ok { + t.Fatalf("expected RichTextSectionEmojiElement, got %T", sec.Elements[0]) + } + if emoji.Name != "bar_chart" { + t.Errorf("expected emoji name %q, got %q", "bar_chart", emoji.Name) + } + }, + }, + { + name: "emoji embedded in text", + input: "Hello :wave: world", + check: func(t *testing.T, blocks []slack.Block) { + rt := blocks[0].(*slack.RichTextBlock) + sec := rt.Elements[0].(*slack.RichTextSection) + // Should be: text("Hello ") + emoji("wave") + text(" world") + if len(sec.Elements) < 3 { + t.Fatalf("expected at least 3 elements, got %d: %s", len(sec.Elements), blockJSON(t, blocks)) + } + // Find the emoji element. + foundEmoji := false + for _, elem := range sec.Elements { + if emoji, ok := elem.(*slack.RichTextSectionEmojiElement); ok { + if emoji.Name == "wave" { + foundEmoji = true + } + } + } + if !foundEmoji { + t.Errorf("expected emoji element with name 'wave': %s", blockJSON(t, blocks)) + } + }, + }, + { + name: "multiple emojis", + input: ":thumbsup: :heart: :fire:", + check: func(t *testing.T, blocks []slack.Block) { + rt := blocks[0].(*slack.RichTextBlock) + sec := rt.Elements[0].(*slack.RichTextSection) + var emojiNames []string + for _, elem := range sec.Elements { + if emoji, ok := elem.(*slack.RichTextSectionEmojiElement); ok { + emojiNames = append(emojiNames, emoji.Name) + } + } + expected := []string{"thumbsup", "heart", "fire"} + if len(emojiNames) != len(expected) { + t.Fatalf("expected %d emojis, got %d: %v — %s", len(expected), len(emojiNames), emojiNames, blockJSON(t, blocks)) + } + for i, name := range expected { + if emojiNames[i] != name { + t.Errorf("emoji[%d]: expected %q, got %q", i, name, emojiNames[i]) + } + } + }, + }, + { + name: "emoji with underscore in name", + input: ":speech_balloon:", + check: func(t *testing.T, blocks []slack.Block) { + rt := blocks[0].(*slack.RichTextBlock) + sec := rt.Elements[0].(*slack.RichTextSection) + emoji, ok := sec.Elements[0].(*slack.RichTextSectionEmojiElement) + if !ok { + t.Fatalf("expected emoji element, got %T: %s", sec.Elements[0], blockJSON(t, blocks)) + } + if emoji.Name != "speech_balloon" { + t.Errorf("expected %q, got %q", "speech_balloon", emoji.Name) + } + }, + }, + { + name: "bold emoji inherits style", + input: "**:fire:**", + check: func(t *testing.T, blocks []slack.Block) { + rt := blocks[0].(*slack.RichTextBlock) + sec := rt.Elements[0].(*slack.RichTextSection) + foundStyledEmoji := false + for _, elem := range sec.Elements { + if emoji, ok := elem.(*slack.RichTextSectionEmojiElement); ok { + if emoji.Name == "fire" && emoji.Style != nil && emoji.Style.Bold { + foundStyledEmoji = true + } + } + } + if !foundStyledEmoji { + t.Errorf("expected bold emoji element: %s", blockJSON(t, blocks)) + } + }, + }, + { + name: "non-emoji colons preserved as text", + input: "time: 10:30 and key:value", + check: func(t *testing.T, blocks []slack.Block) { + rt := blocks[0].(*slack.RichTextBlock) + sec := rt.Elements[0].(*slack.RichTextSection) + // None of these should match as emoji — all should be text elements. + for _, elem := range sec.Elements { + if _, ok := elem.(*slack.RichTextSectionEmojiElement); ok { + t.Errorf("unexpected emoji element in non-emoji text: %s", blockJSON(t, blocks)) + } + } + }, + }, + { + name: "emoji with plus sign", + input: ":+1:", + check: func(t *testing.T, blocks []slack.Block) { + rt := blocks[0].(*slack.RichTextBlock) + sec := rt.Elements[0].(*slack.RichTextSection) + emoji, ok := sec.Elements[0].(*slack.RichTextSectionEmojiElement) + if !ok { + t.Fatalf("expected emoji element, got %T", sec.Elements[0]) + } + if emoji.Name != "+1" { + t.Errorf("expected %q, got %q", "+1", emoji.Name) + } + }, + }, + { + name: "emoji in list item", + input: "- :check: Done\n- :x: Failed", + check: func(t *testing.T, blocks []slack.Block) { + jsonStr := blockJSON(t, blocks) + if !strings.Contains(jsonStr, `"type": "emoji"`) { + t.Errorf("expected emoji elements in list items: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"name": "check"`) { + t.Errorf("expected check emoji in output: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"name": "x"`) { + t.Errorf("expected x emoji in output: %s", jsonStr) + } + }, + }, + { + name: "emoji in blockquote", + input: "> :warning: Caution", + check: func(t *testing.T, blocks []slack.Block) { + jsonStr := blockJSON(t, blocks) + if !strings.Contains(jsonStr, `"type": "emoji"`) { + t.Errorf("expected emoji element in blockquote: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"name": "warning"`) { + t.Errorf("expected warning emoji in output: %s", jsonStr) + } + }, + }, + { + name: "emoji JSON serialization", + input: ":bar_chart: :pencil: :speech_balloon:", + check: func(t *testing.T, blocks []slack.Block) { + data, err := json.Marshal(blocks) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + jsonStr := string(data) + for _, name := range []string{"bar_chart", "pencil", "speech_balloon"} { + if !strings.Contains(jsonStr, `"name":"`+name+`"`) { + t.Errorf("expected emoji name %q in JSON: %s", name, jsonStr) + } + } + // Verify type is "emoji" not "text". + if !strings.Contains(jsonStr, `"type":"emoji"`) { + t.Errorf("expected emoji type in JSON: %s", jsonStr) + } + }, + }, + { + name: "numeric-only colons in inline code not treated as emoji", + input: "- `19:49:41 UTC` \u2014 Job created", + check: func(t *testing.T, blocks []slack.Block) { + jsonStr := blockJSON(t, blocks) + // ":49:" should NOT become an emoji element. + if strings.Contains(jsonStr, `"name": "49"`) { + t.Errorf("pure-digit :49: should not be treated as emoji: %s", jsonStr) + } + // The full timestamp should be preserved as a single text element. + if !strings.Contains(jsonStr, `19:49:41 UTC`) { + t.Errorf("expected timestamp to be preserved as text: %s", jsonStr) + } + }, + }, + { + name: "time-like patterns not treated as emoji", + input: "At `10:30:00` and `23:59:59` the server restarted", + check: func(t *testing.T, blocks []slack.Block) { + jsonStr := blockJSON(t, blocks) + // None of the numeric segments should become emojis. + for _, elem := range []string{`"name": "30"`, `"name": "59"`} { + if strings.Contains(jsonStr, elem) { + t.Errorf("pure-digit colon pattern should not be emoji: %s", jsonStr) + } + } + }, + }, + { + name: "emoji in inline code preserved as literal text", + input: "Use `:fire:` for emphasis", + check: func(t *testing.T, blocks []slack.Block) { + jsonStr := blockJSON(t, blocks) + // :fire: inside backticks should NOT become an emoji element. + if strings.Contains(jsonStr, `"type": "emoji"`) { + t.Errorf("emoji inside inline code should remain as text: %s", jsonStr) + } + // The literal text should be preserved. + if !strings.Contains(jsonStr, `:fire:`) { + t.Errorf("expected literal :fire: in code span: %s", jsonStr) + } + }, + }, + { + name: "adjacent invalid and valid shortcodes", + input: "19:49::wave: hello", + check: func(t *testing.T, blocks []slack.Block) { + jsonStr := blockJSON(t, blocks) + // :49: is invalid (pure digit), :wave: is valid. + if strings.Contains(jsonStr, `"name": "49"`) { + t.Errorf(":49: should not be emoji: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"name": "wave"`) { + t.Errorf("expected :wave: emoji: %s", jsonStr) + } + }, + }, + { + name: "emoji in table cell", + input: "| Status |\n|---|\n| :check: Done |", + check: func(t *testing.T, blocks []slack.Block) { + jsonStr := blockJSON(t, blocks) + if !strings.Contains(jsonStr, `"type": "emoji"`) { + t.Errorf("expected emoji element in table cell: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"name": "check"`) { + t.Errorf("expected check emoji in table cell: %s", jsonStr) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + blocks, err := Convert(tt.input) + if err != nil { + t.Fatalf("Convert error: %v", err) + } + tt.check(t, blocks) + }) + } +} + +// TestConvert_TableRowLimit verifies that a table with more than 100 rows +// (including header) is split into multiple TableBlocks. +func TestConvert_TableRowLimit(t *testing.T) { + // Build a markdown table with 1 header + 150 data rows. + var sb strings.Builder + sb.WriteString("| ID | Value |\n|---|---|\n") + for i := 0; i < 150; i++ { + sb.WriteString("| ") + sb.WriteString(strings.Repeat("x", 3)) + sb.WriteString(" | ") + sb.WriteString(strings.Repeat("y", 3)) + sb.WriteString(" |\n") + } + + blocks, err := Convert(sb.String()) + if err != nil { + t.Fatalf("Convert error: %v", err) + } + + // With 1 header + 150 data rows, we need 2 tables: + // table 1: header + 99 data rows = 100 rows + // table 2: header + 51 data rows = 52 rows + if len(blocks) != 2 { + t.Fatalf("expected 2 TableBlocks, got %d: %s", len(blocks), blockJSON(t, blocks)) + } + + tb1, ok := blocks[0].(*slack.TableBlock) + if !ok { + t.Fatalf("block[0]: expected TableBlock, got %T", blocks[0]) + } + tb2, ok := blocks[1].(*slack.TableBlock) + if !ok { + t.Fatalf("block[1]: expected TableBlock, got %T", blocks[1]) + } + + // First table: header + 99 data rows = 100 rows. + if len(tb1.Rows) != 100 { + t.Errorf("table 1: expected 100 rows, got %d", len(tb1.Rows)) + } + // Second table: header + 51 data rows = 52 rows. + if len(tb2.Rows) != 52 { + t.Errorf("table 2: expected 52 rows, got %d", len(tb2.Rows)) + } + + // Both should have unique block IDs. + if tb1.BlockID == tb2.BlockID { + t.Errorf("expected unique block IDs, both are %q", tb1.BlockID) + } +} + +// TestConvert_TableExactlyAtLimit verifies that a table with exactly 100 rows +// (header + 99 data) produces a single TableBlock. +func TestConvert_TableExactlyAtLimit(t *testing.T) { + var sb strings.Builder + sb.WriteString("| Col |\n|---|\n") + for i := 0; i < 99; i++ { + sb.WriteString("| val |\n") + } + + blocks, err := Convert(sb.String()) + if err != nil { + t.Fatalf("Convert error: %v", err) + } + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + tb := blocks[0].(*slack.TableBlock) + if len(tb.Rows) != 100 { + t.Errorf("expected 100 rows, got %d", len(tb.Rows)) + } +} + +// TestConvert_TableOneOverLimit verifies that a table with 101 rows +// (header + 100 data) splits into exactly 2 TableBlocks. +func TestConvert_TableOneOverLimit(t *testing.T) { + var sb strings.Builder + sb.WriteString("| Col |\n|---|\n") + for i := 0; i < 100; i++ { + sb.WriteString("| val |\n") + } + + blocks, err := Convert(sb.String()) + if err != nil { + t.Fatalf("Convert error: %v", err) + } + if len(blocks) != 2 { + t.Fatalf("expected 2 blocks, got %d", len(blocks)) + } + tb1 := blocks[0].(*slack.TableBlock) + tb2 := blocks[1].(*slack.TableBlock) + // First table: header + 99 data rows = 100 rows. + if len(tb1.Rows) != 100 { + t.Errorf("table 1: expected 100 rows, got %d", len(tb1.Rows)) + } + // Second table: header + 1 data row = 2 rows. + if len(tb2.Rows) != 2 { + t.Errorf("table 2: expected 2 rows, got %d", len(tb2.Rows)) + } +} + +// TestConvert_TableColumnLimit verifies that tables with more than 20 columns +// are truncated to 20. +func TestConvert_TableColumnLimit(t *testing.T) { + // Build a 25-column table. + var sb strings.Builder + for i := 0; i < 25; i++ { + if i > 0 { + sb.WriteString(" | ") + } + sb.WriteString("H") + } + sb.WriteString("\n") + for i := 0; i < 25; i++ { + if i > 0 { + sb.WriteString(" | ") + } + sb.WriteString("---") + } + sb.WriteString("\n") + for i := 0; i < 25; i++ { + if i > 0 { + sb.WriteString(" | ") + } + sb.WriteString("D") + } + sb.WriteString("\n") + + blocks, err := Convert(sb.String()) + if err != nil { + t.Fatalf("Convert error: %v", err) + } + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + tb := blocks[0].(*slack.TableBlock) + + // All rows should have at most 20 columns. + for i, row := range tb.Rows { + if len(row) > 20 { + t.Errorf("row %d: expected at most 20 columns, got %d", i, len(row)) + } + } + // Column settings should be at most 20. + if len(tb.ColumnSettings) > 20 { + t.Errorf("expected at most 20 column settings, got %d", len(tb.ColumnSettings)) + } +} + +// TestConvert_TableNoHeaderSplit verifies that a table with header + 200 data rows +// splits into 3 TableBlocks. +func TestConvert_TableNoHeaderSplit(t *testing.T) { + // Build a table with header + 200 data rows to verify splitting. + var sb strings.Builder + sb.WriteString("| A |\n|---|\n") + for i := 0; i < 200; i++ { + sb.WriteString("| x |\n") + } + + blocks, err := Convert(sb.String()) + if err != nil { + t.Fatalf("Convert error: %v", err) + } + + // 200 data rows + header → ceil(200/99) = 3 tables. + // Table 1: header + 99 = 100 rows + // Table 2: header + 99 = 100 rows + // Table 3: header + 2 = 3 rows + if len(blocks) != 3 { + t.Fatalf("expected 3 blocks, got %d", len(blocks)) + } + + tb1 := blocks[0].(*slack.TableBlock) + tb2 := blocks[1].(*slack.TableBlock) + tb3 := blocks[2].(*slack.TableBlock) + + if len(tb1.Rows) != 100 { + t.Errorf("table 1: expected 100 rows, got %d", len(tb1.Rows)) + } + if len(tb2.Rows) != 100 { + t.Errorf("table 2: expected 100 rows, got %d", len(tb2.Rows)) + } + if len(tb3.Rows) != 3 { + t.Errorf("table 3: expected 3 rows, got %d", len(tb3.Rows)) + } +} + // FuzzConvert verifies that Convert never panics on arbitrary input. func FuzzConvert(f *testing.F) { f.Add("") @@ -1506,6 +1961,7 @@ func FuzzConvert(f *testing.F) { f.Add("- [x] done\n- [ ] todo") f.Add("***bold italic***") f.Add("~~strikethrough~~") + f.Add(":bar_chart: hello :wave: world :+1:") f.Fuzz(func(t *testing.T, input string) { _, err := Convert(input) diff --git a/table.go b/table.go index a073185..b947d2c 100644 --- a/table.go +++ b/table.go @@ -7,6 +7,13 @@ import ( east "github.com/yuin/goldmark/extension/ast" ) +const ( + // Slack TableBlock API limits. + // https://docs.slack.dev/reference/block-kit/blocks/table-block/ + maxTableRows = 100 // including header row + maxTableColumns = 20 +) + // tableState accumulates table data during AST walking. type tableState struct { alignments []east.Alignment @@ -29,7 +36,9 @@ func (ctx *renderContext) handleTable(n *east.Table, entering bool) { return } - ctx.emitBlock(ctx.tableState.renderTableBlock(ctx)) + for _, tb := range ctx.tableState.renderTableBlocks(ctx) { + ctx.emitBlock(tb) + } ctx.tableState = nil } } @@ -80,8 +89,9 @@ func (ctx *renderContext) handleTableCell(_ *east.TableCell, entering bool) { elements = append(elements, sec) } if len(elements) == 0 { - // Empty cell: provide a minimal RichTextSection with a single space - // so Slack API receives non-empty text (it rejects "text":""). + // Empty cell: provide a minimal RichTextSection so Slack API + // receives "elements": [...] rather than null. + // Note: Slack rejects empty string text elements, so use a space. elements = []slack.RichTextElement{ slack.NewRichTextSection( slack.NewRichTextSectionTextElement(" ", nil), @@ -93,39 +103,81 @@ func (ctx *renderContext) handleTableCell(_ *east.TableCell, entering bool) { } } -// renderTableBlock builds a *slack.TableBlock from the accumulated table data. -func (ts *tableState) renderTableBlock(ctx *renderContext) *slack.TableBlock { - ctx.actionCounter++ - blockID := fmt.Sprintf("table-%d", ctx.actionCounter) - tb := slack.NewTableBlock(blockID) +// renderTableBlocks builds one or more [slack.TableBlock] from the accumulated +// table data. Slack enforces a maximum of [maxTableRows] rows (including the +// header) and [maxTableColumns] columns per table. When the data exceeds the +// row limit, it is split across multiple TableBlocks, each carrying the same +// header. Columns beyond [maxTableColumns] are silently truncated. +func (ts *tableState) renderTableBlocks(ctx *renderContext) []*slack.TableBlock { + header := truncateRow(ts.headerRow, maxTableColumns) + alignments := ts.alignments + if len(alignments) > maxTableColumns { + alignments = alignments[:maxTableColumns] + } - // Add header row. - if len(ts.headerRow) > 0 { - tb.AddRow(ts.headerRow...) + // Maximum data rows per table: total limit minus 1 for the header (if present). + maxDataRows := maxTableRows + if len(header) > 0 { + maxDataRows = maxTableRows - 1 } - // Add data rows. - for _, row := range ts.dataRows { - tb.AddRow(row...) + var tables []*slack.TableBlock + for start := 0; start < len(ts.dataRows) || len(tables) == 0; { + end := start + maxDataRows + if end > len(ts.dataRows) { + end = len(ts.dataRows) + } + chunk := ts.dataRows[start:end] + + ctx.actionCounter++ + blockID := fmt.Sprintf("table-%d", ctx.actionCounter) + tb := slack.NewTableBlock(blockID) + + if len(header) > 0 { + tb.AddRow(header...) + } + for _, row := range chunk { + tb.AddRow(truncateRow(row, maxTableColumns)...) + } + + tb.WithColumnSettings(buildColumnSettings(header, chunk, alignments)...) + tables = append(tables, tb) + start = end } - // Determine column count. - numCols := 0 - if len(ts.headerRow) > numCols { - numCols = len(ts.headerRow) + return tables +} + +// truncateRow returns the row trimmed to at most maxCols cells. +func truncateRow(row []*slack.RichTextBlock, maxCols int) []*slack.RichTextBlock { + if len(row) <= maxCols { + return row } - for _, row := range ts.dataRows { - if len(row) > numCols { - numCols = len(row) + return row[:maxCols] +} + +// buildColumnSettings creates column settings based on the actual column count +// (capped at maxTableColumns) from the header and data rows. +func buildColumnSettings(header []*slack.RichTextBlock, dataRows [][]*slack.RichTextBlock, alignments []east.Alignment) []slack.ColumnSetting { + numCols := len(header) + for _, row := range dataRows { + n := len(row) + if n > maxTableColumns { + n = maxTableColumns + } + if n > numCols { + numCols = n } } + if numCols > maxTableColumns { + numCols = maxTableColumns + } - // Build column settings with alignment and wrapping. settings := make([]slack.ColumnSetting, numCols) for i := range settings { settings[i].IsWrapped = true - if i < len(ts.alignments) { - switch ts.alignments[i] { + if i < len(alignments) { + switch alignments[i] { case east.AlignRight: settings[i].Align = slack.ColumnAlignmentRight case east.AlignCenter: @@ -137,7 +189,5 @@ func (ts *tableState) renderTableBlock(ctx *renderContext) *slack.TableBlock { settings[i].Align = slack.ColumnAlignmentLeft } } - tb.WithColumnSettings(settings...) - - return tb + return settings }