Skip to content
Merged
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
8 changes: 4 additions & 4 deletions blocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
118 changes: 116 additions & 2 deletions context.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading