Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project

md2slack is a Go library that converts standard Markdown into Slack Block Kit blocks using goldmark (GFM parser) and slack-go/slack (canonical Block Kit types). One public function: `Convert` returns `[]slack.Block`. A helper `ChunkBlocks` splits block slices for the 50-block-per-message limit. Requires Go 1.22+.
md2slack is a Go library that converts standard Markdown into Slack Block Kit blocks using goldmark (GFM parser) and slack-go/slack (canonical Block Kit types). One public function: `Convert` returns `[]slack.Block`. A helper `ChunkBlocks` splits block slices for the 50-block-per-message limit and ensures at most one `TableBlock` per chunk (Slack rejects messages with multiple tables). Requires Go 1.25+.

## Commands

Expand All @@ -22,7 +22,7 @@ Single flat package `md2slack` with a custom goldmark AST walker that builds `[]
- `context.go` — `renderContext` struct that tracks all rendering state: output `blocks` accumulator, `inlineElements` for current inline content, `styleStack` for nested bold/italic/strike/code, heading state (`headingBuf`/`headingMrkdwnBuf` builders for plain text and mrkdwn fallback), `blockquoteStack` for nested blockquotes, `listStack` for nested lists, table/link/image state, `actionCounter` for unique IDs. Contains style stack methods (`pushStyle`, `popStyle`, `recomputeStyle`), inline helpers (`addText`, `addLink`, `flushInlineToSection`), and block emission (`emitBlock`).
- `blocks.go` — Block-level AST node handlers: `handleDocument`, `handleHeading` (smart HeaderBlock vs SectionBlock fallback for links or >150 chars), `handleParagraph` (standalone image → ImageBlock, standalone link → ActionBlock, normal → RichTextBlock), `handleBlockquote` (RichTextQuote), `handleFencedCodeBlock`/`handleCodeBlock` (RichTextPreformatted), `handleList`/`handleListItem` (RichTextList with nested indent), `handleThematicBreak` (DividerBlock).
- `inlines.go` — Inline AST node handlers: `handleText` (text with style stack), `handleString`, `handleEmphasis` (level 1=italic, 2=bold), `handleCodeSpan` (Code style), `handleLink` (RichTextSectionLinkElement), `handleImage`, `handleAutoLink`, `handleStrikethrough` (Strike style), `handleTaskCheckBox` (checkbox emoji).
- `table.go` — GFM table handlers: `handleTable`, `handleTableHeader`, `handleTableRow`, `handleTableCell`. Accumulates cell text in `tableState`, renders as code-fenced monospace SectionBlock with column alignment (`padCellAligned`). No native Slack TableBlock exists in slack-go.
- `table.go` — GFM table handlers: `handleTable`, `handleTableHeader`, `handleTableRow`, `handleTableCell`. Accumulates cells as `*slack.RichTextBlock` in `tableState`, renders as native `slack.TableBlock` with per-column alignment and wrapping. Inline formatting (bold, links, code) is preserved in cells via the standard inline pipeline.
- `doc.go` — Package-level godoc.
- `cmd/example/main.go` — Example CLI that reads markdown from stdin/file, calls Convert and ChunkBlocks, prints JSON.

Expand Down
2 changes: 1 addition & 1 deletion DEMO.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This guide walks you through using the `cmd/demo/` program to convert Markdown t

| What | How to get it |
|------|---------------|
| **Go 1.22+** | Install from <https://go.dev/dl/> — run `go version` to verify |
| **Go 1.25+** | Install from <https://go.dev/dl/> — run `go version` to verify |
| **Slack Bot Token** | Create an app at <https://api.slack.com/apps>, add the `chat:write` bot scope, install to your workspace, copy the `xoxb-…` token |
| **Channel ID** | In Slack, right-click a channel → *View channel details* → copy the ID at the bottom (e.g. `C0123456789`). Invite the bot to the channel |
| **Claude Code** *(optional)* | Install from <https://docs.claude.com/en/docs/setup> — only needed for the Claude-generated example |
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
go get github.com/navidemad/md2slack
```

Requires **Go 1.22+**.
Requires **Go 1.25+**.

## Quick start

Expand Down Expand Up @@ -72,7 +72,7 @@ Output blocks (as JSON):
| `> quote` | `rich_text` (`rich_text_quote`) |
| Fenced code blocks (`` ``` ``) | `rich_text` (`rich_text_preformatted`) |
| `1. item` / `- item` | `rich_text` (`rich_text_list` with ordered/bullet style, nested indent) |
| GFM tables | `section` (mrkdwn with code-fenced monospace, column-aligned) |
| GFM tables | `table` (native TableBlock with rich text cells, per-column alignment and wrapping) |
| Inline text with formatting | `rich_text` (`rich_text_section` with styled elements) |

### Inline formatting
Expand Down Expand Up @@ -165,7 +165,7 @@ blocks, _ := md2slack.Convert("[Click here](https://example.com)")

### Message chunking

Slack limits messages to 50 blocks. Use `ChunkBlocks` to split:
Slack limits messages to 50 blocks. Use `ChunkBlocks` to split. It also enforces at most one `TableBlock` per chunk, since Slack rejects messages containing multiple tables.

```go
blocks, _ := md2slack.Convert(longMarkdown)
Expand All @@ -183,7 +183,7 @@ Parses a Markdown string and returns Slack Block Kit blocks. Returns `nil, nil`

### `ChunkBlocks(blocks []slack.Block, maxPerMessage int) [][]slack.Block`

Splits a block slice into chunks of at most `maxPerMessage`. Defaults to 50 if `maxPerMessage <= 0`.
Splits a block slice into chunks of at most `maxPerMessage`. Defaults to 50 if `maxPerMessage <= 0`. Each chunk contains at most one `TableBlock` — when a table is encountered, the current chunk is finalized and a new chunk begins after the table.

## CLI example

Expand Down
6 changes: 4 additions & 2 deletions blocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +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.
if len(ctx.inlineElements) > 0 {
sec := slack.NewRichTextSection(ctx.inlineElements...)
resolved := resolveEmojis(ctx.inlineElements)
sec := slack.NewRichTextSection(resolved...)
ctx.emitBlock(slack.NewRichTextBlock("", sec))
ctx.inlineElements = nil
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ import (
"path"
"strings"

"github.com/navidemad/md2slack"
"github.com/presmihaylov/md2slack"
"github.com/slack-go/slack"
)

const sampleMarkdown = `# Hello from md2slack

This is **bold**, _italic_, and ~~strikethrough~~.

Here is some ` + "`inline code`" + ` and a [link](https://github.com/navidemad/md2slack).
Here is some ` + "`inline code`" + ` and a [link](https://github.com/presmihaylov/md2slack).

` + "```go" + `
func main() {
Expand Down
2 changes: 1 addition & 1 deletion cmd/example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"io"
"os"

"github.com/navidemad/md2slack"
"github.com/presmihaylov/md2slack"
)

func main() {
Expand Down
102 changes: 100 additions & 2 deletions context.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package md2slack

import (
"regexp"
"strings"

"github.com/slack-go/slack"
)

// emojiShortcodeRe matches Slack emoji shortcodes like :bar_chart:, :+1:, :wave:.
// Slack emoji names contain lowercase letters, digits, underscores, hyphens, and plus signs.
var emojiShortcodeRe = regexp.MustCompile(`:([a-z0-9+][a-z0-9_+\-]*):`)


Check failure on line 14 in context.go

View workflow job for this annotation

GitHub Actions / lint

File is not properly formatted (gofmt)
// renderContext tracks all state during the AST walk.
type renderContext struct {
source []byte
Expand Down Expand Up @@ -139,18 +145,110 @@
}

// 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 {
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 {
if loc[0] > cursor {
result = append(result, slack.NewRichTextSectionTextElement(te.Text[cursor:loc[0]], te.Style))
}
name := te.Text[loc[0]+1 : loc[1]-1]
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 {
return
}
ctx.blocks = append(ctx.blocks, b)
}

Expand Down
8 changes: 5 additions & 3 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
// payloads. Headings become HeaderBlocks, horizontal rules become
// DividerBlocks, images become ImageBlocks, fenced code becomes
// RichTextPreformatted, lists become RichTextList, blockquotes become
// RichTextQuote, tables become SectionBlocks with code-fenced monospace,
// RichTextQuote, tables become TableBlocks with rich text cells,
// and inline text with formatting becomes RichTextSection elements.
//
// - [ChunkBlocks] splits a block slice into chunks of at most N blocks,
// useful for respecting Slack's 50-block-per-message limit.
// useful for respecting Slack's 50-block-per-message limit. It also
// ensures each chunk contains at most one TableBlock, since Slack
// rejects messages with multiple tables.
//
// # Supported Markdown features
//
Expand All @@ -30,7 +32,7 @@
// - Ordered lists (1. item) → RichTextList (ordered)
// - Unordered lists (- item) → RichTextList (bullet)
// - Nested lists → RichTextList with indent levels
// - GFM tables → SectionBlock with code-fenced monospace
// - GFM tables → TableBlock with rich text cells
// - Horizontal rules (---) → DividerBlock
// - Standalone links → ActionBlock with button
// - Task checkboxes (- [x] item) → checkbox emoji
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
module github.com/navidemad/md2slack
module github.com/presmihaylov/md2slack

go 1.22
go 1.25

require (
github.com/slack-go/slack v0.17.3
github.com/slack-go/slack v0.18.0
github.com/yuin/goldmark v1.7.16
)

Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/slack-go/slack v0.18.0 h1:PM3IWgAoaPTnitOyfy8Unq/rk8OZLAxlBUhNLv8sbyg=
github.com/slack-go/slack v0.18.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
25 changes: 2 additions & 23 deletions inlines.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,6 @@ func (ctx *renderContext) handleText(n *ast.Text, entering bool) {
return
}

// Inside a table cell, write to the cell buffer (plain text, no formatting).
if ctx.inTable && ctx.tableState != nil {
ctx.tableState.cellBuf.WriteString(text)
return
}

// Inside a link, accumulate text for the link display text.
if ctx.inLink {
ctx.linkTextBuf += text
Expand Down Expand Up @@ -67,10 +61,6 @@ func (ctx *renderContext) handleString(n *ast.String, entering bool) {
}
return
}
if ctx.inTable && ctx.tableState != nil {
ctx.tableState.cellBuf.WriteString(text)
return
}
if ctx.inLink {
ctx.linkTextBuf += text
return
Expand Down Expand Up @@ -122,11 +112,6 @@ func (ctx *renderContext) handleCodeSpan(n *ast.CodeSpan, entering bool) {
return
}

if ctx.inTable && ctx.tableState != nil {
ctx.tableState.cellBuf.WriteString(text)
return
}

if ctx.inLink {
ctx.linkTextBuf += text
return
Expand Down Expand Up @@ -161,8 +146,6 @@ func (ctx *renderContext) handleLink(n *ast.Link, entering bool) {
ctx.headingMrkdwnBuf.WriteString("|")
ctx.headingMrkdwnBuf.WriteString(ctx.linkTextBuf)
ctx.headingMrkdwnBuf.WriteString(">")
case ctx.inTable && ctx.tableState != nil:
// Link text already written to cellBuf via handleText.
default:
ctx.addLink(ctx.linkURL, ctx.linkTextBuf)
}
Expand Down Expand Up @@ -192,12 +175,8 @@ func (ctx *renderContext) handleImage(n *ast.Image, entering bool) {
ctx.headingMrkdwnBuf.WriteString(ctx.imageAlt)
}

// In tables, write alt text to cell buffer.
if ctx.inTable && ctx.tableState != nil {
ctx.tableState.cellBuf.WriteString(alt)
}
// Inline image (not standalone, not heading, not table): fall back to link.
if !ctx.inHeading && (!ctx.inTable || ctx.tableState == nil) && !ctx.isStandaloneImage {
// Inline image (not standalone, not heading): fall back to link.
if !ctx.inHeading && !ctx.isStandaloneImage {
label := ctx.imageAlt
if label == "" {
label = ctx.imageURL
Expand Down
Loading
Loading