Skip to content

Commit b714566

Browse files
isaacrowntreeclaude
andcommitted
feat(send): auto-convert Markdown to Slack mrkdwn with --blocks and --raw flags
Messages now auto-convert standard Markdown (**bold**, # headers, [links](url)) to Slack mrkdwn (*bold*, etc). Formatting hints shown on stderr when conversions are applied. --blocks sends via Block Kit sections for richer rendering with automatic splitting at Slack's 3000-char limit. --raw disables auto-conversion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 58af258 commit b714566

3 files changed

Lines changed: 550 additions & 7 deletions

File tree

internal/text/mrkdwn.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package text
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strings"
7+
)
8+
9+
// Slack mrkdwn reference:
10+
// *bold* _italic_ ~strikethrough~ `code` ```code block```
11+
// > blockquote • or - for bullets (- works natively)
12+
// <url|label> for links :emoji: shortcodes
13+
// No headers (#), no **bold**, no [links](url), no ![images](url)
14+
15+
var (
16+
// Markdown bold: **text** or __text__ → *text*
17+
mdBoldDoubleAsterisk = regexp.MustCompile(`\*\*(.+?)\*\*`)
18+
mdBoldDoubleUnderscore = regexp.MustCompile(`__(.+?)__`)
19+
20+
// Markdown italic: *text* is already valid mrkdwn, but _text_ also works.
21+
// We only need to handle cases where markdown uses single * for italic
22+
// and the user also has **bold** — the bold conversion handles that.
23+
24+
// Markdown headers: # Header, ## Header, ### Header
25+
// Convert to *bold text* on its own line
26+
mdHeader = regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`)
27+
28+
// Markdown links: [text](url) → <url|text>
29+
mdLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
30+
31+
// Markdown images: ![alt](url) → <url|alt> (best effort)
32+
mdImage = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
33+
34+
// Markdown horizontal rules: --- or *** or ___ on their own line
35+
// Convert to ───── divider (Slack has no native HR in mrkdwn text)
36+
mdHorizontalRule = regexp.MustCompile(`(?m)^[\s]*([-*_]){3,}\s*$`)
37+
38+
// Markdown ordered list: 1. item → 1. item (already works, but detect for hints)
39+
mdOrderedList = regexp.MustCompile(`(?m)^\s*\d+\.\s+`)
40+
41+
// Detect markdown-style bold that won't render in Slack
42+
detectDoubleBold = regexp.MustCompile(`\*\*[^*]+\*\*`)
43+
44+
// Detect markdown headers
45+
detectHeaders = regexp.MustCompile(`(?m)^#{1,6}\s+`)
46+
47+
// Detect markdown links
48+
detectLinks = regexp.MustCompile(`\[[^\]]+\]\([^)]+\)`)
49+
50+
// Detect markdown images
51+
detectImages = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`)
52+
)
53+
54+
// FormatHint describes a formatting issue detected in the message text.
55+
type FormatHint struct {
56+
Issue string // What was detected
57+
Suggestion string // How to fix it for Slack
58+
Example string // Before → After example
59+
}
60+
61+
// ConvertMarkdownToMrkdwn converts common Markdown syntax to Slack mrkdwn.
62+
// It handles bold, headers, links, images, and horizontal rules.
63+
// Already-valid mrkdwn (like *bold*, _italic_, ~strike~, `code`) passes through unchanged.
64+
func ConvertMarkdownToMrkdwn(text string) string {
65+
// Order matters: images before links (images contain link syntax)
66+
67+
// Images: ![alt](url) → <url|alt> or just <url>
68+
text = mdImage.ReplaceAllStringFunc(text, func(match string) string {
69+
parts := mdImage.FindStringSubmatch(match)
70+
alt, url := parts[1], parts[2]
71+
if alt != "" {
72+
return fmt.Sprintf("<%s|%s>", url, alt)
73+
}
74+
return fmt.Sprintf("<%s>", url)
75+
})
76+
77+
// Links: [text](url) → <url|text>
78+
text = mdLink.ReplaceAllStringFunc(text, func(match string) string {
79+
parts := mdLink.FindStringSubmatch(match)
80+
label, url := parts[1], parts[2]
81+
return fmt.Sprintf("<%s|%s>", url, label)
82+
})
83+
84+
// Headers first: # Text → *Text* (before bold, so nested **bold** in headers
85+
// gets cleaned up by the bold pass)
86+
text = mdHeader.ReplaceAllStringFunc(text, func(match string) string {
87+
parts := mdHeader.FindStringSubmatch(match)
88+
// Strip any ** markers inside the header text before wrapping in *
89+
inner := mdBoldDoubleAsterisk.ReplaceAllString(parts[2], "$1")
90+
inner = mdBoldDoubleUnderscore.ReplaceAllString(inner, "$1")
91+
return "*" + inner + "*"
92+
})
93+
94+
// Bold: **text** → *text*
95+
text = mdBoldDoubleAsterisk.ReplaceAllString(text, "*$1*")
96+
text = mdBoldDoubleUnderscore.ReplaceAllString(text, "*$1*")
97+
98+
// Horizontal rules: --- → ─────
99+
text = mdHorizontalRule.ReplaceAllString(text, "─────")
100+
101+
return text
102+
}
103+
104+
// DetectFormatHints inspects message text and returns hints about Markdown
105+
// syntax that won't render correctly in Slack. Call this BEFORE conversion
106+
// to give the user actionable feedback.
107+
func DetectFormatHints(text string) []FormatHint {
108+
var hints []FormatHint
109+
110+
if detectDoubleBold.MatchString(text) {
111+
hints = append(hints, FormatHint{
112+
Issue: "Markdown bold (**text**) won't render in Slack",
113+
Suggestion: "Use *text* for bold in Slack mrkdwn",
114+
Example: "**bold** → *bold*",
115+
})
116+
}
117+
118+
if detectHeaders.MatchString(text) {
119+
hints = append(hints, FormatHint{
120+
Issue: "Markdown headers (# Header) aren't supported in Slack",
121+
Suggestion: "Use *bold text* on its own line for emphasis",
122+
Example: "# Section → *Section*",
123+
})
124+
}
125+
126+
if detectLinks.MatchString(text) {
127+
hints = append(hints, FormatHint{
128+
Issue: "Markdown links [text](url) won't render in Slack",
129+
Suggestion: "Use <url|text> for Slack links",
130+
Example: "[Click here](https://...) → <https://...|Click here>",
131+
})
132+
}
133+
134+
if detectImages.MatchString(text) {
135+
hints = append(hints, FormatHint{
136+
Issue: "Markdown images ![alt](url) aren't supported in Slack",
137+
Suggestion: "Use <url|alt> — Slack will unfurl image URLs automatically",
138+
Example: "![logo](https://...) → <https://...|logo>",
139+
})
140+
}
141+
142+
return hints
143+
}
144+
145+
// SlackMrkdwnReference returns a quick-reference string for Slack mrkdwn syntax.
146+
func SlackMrkdwnReference() string {
147+
return strings.TrimSpace(`
148+
Slack mrkdwn formatting reference:
149+
*bold* Bold text
150+
_italic_ Italic text
151+
~strikethrough~ Strikethrough
152+
` + "`code`" + ` Inline code
153+
` + "```code```" + ` Code block
154+
> quote Blockquote
155+
• item Bullet list (or - item)
156+
1. item Numbered list
157+
<url|label> Hyperlink
158+
:emoji: Emoji shortcode
159+
@user Mention (auto-resolved by slackbuzz)
160+
`)
161+
}

0 commit comments

Comments
 (0)