package markdown import ( "bytes" "fmt" "html/template" "regexp" "strings" "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" ) var ( md goldmark.Markdown policy *bluemonday.Policy // Matches @username in rendered HTML text (not inside tags) mentionRegex = regexp.MustCompile(`(?:^|[\s(>])(@(\w+))`) // Matches @username in raw markdown for extraction RawMentionRegex = regexp.MustCompile(`(?:^|[\s(])@(\w+)`) // Matches :shortcode: patterns for emoji replacement emojiRegex = regexp.MustCompile(`:(\w+):`) // Matches mermaid code blocks in rendered HTML mermaidBlockRegex = regexp.MustCompile(`(?s)]*>]*class="[^"]*language-mermaid[^"]*"[^>]*>(.*?)\s*`) // emojiMap maps shortcode names to Unicode emoji characters. emojiMap = map[string]string{ // Smileys & Emotion "smile": "😄", "laughing": "😆", "blush": "😊", "smiley": "😃", "relaxed": "â˜ēī¸", "smirk": "😏", "heart_eyes": "😍", "kissing_heart": "😘", "kissing_closed_eyes": "😚", "flushed": "đŸ˜ŗ", "relieved": "😌", "satisfied": "😆", "grin": "😁", "wink": "😉", "stuck_out_tongue_winking_eye": "😜", "stuck_out_tongue": "😛", "sleeping": "😴", "worried": "😟", "frowning": "đŸ˜Ļ", "anguished": "😧", "open_mouth": "😮", "grimacing": "đŸ˜Ŧ", "confused": "😕", "hushed": "đŸ˜¯", "expressionless": "😑", "unamused": "😒", "sweat_smile": "😅", "sweat": "😓", "disappointed_relieved": "đŸ˜Ĩ", "weary": "😩", "pensive": "😔", "disappointed": "😞", "confounded": "😖", "fearful": "😨", "cold_sweat": "😰", "persevere": "đŸ˜Ŗ", "cry": "đŸ˜ĸ", "sob": "😭", "joy": "😂", "astonished": "😲", "scream": "😱", "tired_face": "đŸ˜Ģ", "angry": "😠", "rage": "😡", "triumph": "😤", "sleepy": "đŸ˜Ē", "yum": "😋", "mask": "😷", "sunglasses": "😎", "dizzy_face": "đŸ˜ĩ", "imp": "đŸ‘ŋ", "smiling_imp": "😈", "neutral_face": "😐", "no_mouth": "đŸ˜ļ", "innocent": "😇", "alien": "đŸ‘Ŋ", "yellow_heart": "💛", "blue_heart": "💙", "purple_heart": "💜", "heart": "â¤ī¸", "green_heart": "💚", "broken_heart": "💔", "heartbeat": "💓", "heartpulse": "💗", "two_hearts": "💕", "sparkling_heart": "💖", "star": "⭐", "star2": "🌟", "dizzy": "đŸ’Ģ", "boom": "đŸ’Ĩ", "anger": "đŸ’ĸ", "exclamation": "❗", "question": "❓", "grey_exclamation": "❕", "grey_question": "❔", "zzz": "💤", "dash": "💨", "sweat_drops": "đŸ’Ļ", "notes": "đŸŽļ", "musical_note": "đŸŽĩ", "fire": "đŸ”Ĩ", "poop": "💩", "thumbsup": "👍", "+1": "👍", "thumbsdown": "👎", "-1": "👎", "ok_hand": "👌", "punch": "👊", "fist": "✊", "v": "âœŒī¸", "wave": "👋", "hand": "✋", "open_hands": "👐", "point_up": "â˜ī¸", "point_down": "👇", "point_left": "👈", "point_right": "👉", "raised_hands": "🙌", "pray": "🙏", "clap": "👏", "muscle": "đŸ’Ē", "eyes": "👀", "tongue": "👅", "lips": "👄", // People "boy": "đŸ‘Ļ", "girl": "👧", "woman": "👩", "man": "👨", "baby": "đŸ‘ļ", "older_man": "👴", "older_woman": "đŸ‘ĩ", "skull": "💀", "ghost": "đŸ‘ģ", "robot": "🤖", // Nature "sunny": "â˜€ī¸", "umbrella": "â˜‚ī¸", "cloud": "â˜ī¸", "snowflake": "â„ī¸", "snowman": "⛄", "zap": "⚡", "cyclone": "🌀", "foggy": "🌁", "rainbow": "🌈", "ocean": "🌊", "dog": "đŸļ", "cat": "🐱", "mouse": "🐭", "hamster": "🐹", "rabbit": "🐰", "bear": "đŸģ", "panda_face": "đŸŧ", "pig": "🐷", "frog": "🐸", "monkey_face": "đŸĩ", "see_no_evil": "🙈", "hear_no_evil": "🙉", "speak_no_evil": "🙊", "chicken": "🐔", "penguin": "🐧", "bird": "đŸĻ", "fish": "🐟", "whale": "đŸŗ", "bug": "🐛", "honeybee": "🐝", "beetle": "🐞", "snail": "🐌", "octopus": "🐙", "turtle": "đŸĸ", "snake": "🐍", "crab": "đŸĻ€", "unicorn": "đŸĻ„", // Food & Drink "apple": "🍎", "green_apple": "🍏", "pear": "🍐", "tangerine": "🍊", "lemon": "🍋", "banana": "🍌", "watermelon": "🍉", "grapes": "🍇", "strawberry": "🍓", "peach": "🍑", "cherries": "🍒", "pizza": "🍕", "hamburger": "🍔", "fries": "🍟", "hotdog": "🌭", "taco": "🌮", "burrito": "đŸŒ¯", "egg": "đŸĨš", "coffee": "☕", "tea": "đŸĩ", "beer": "đŸē", "beers": "đŸģ", "wine_glass": "🍷", "cocktail": "🍸", "cake": "🍰", "cookie": "đŸĒ", "chocolate_bar": "đŸĢ", "candy": "đŸŦ", "icecream": "đŸĻ", "doughnut": "🍩", // Objects "rocket": "🚀", "airplane": "âœˆī¸", "car": "🚗", "taxi": "🚕", "bus": "🚌", "ambulance": "🚑", "fire_engine": "🚒", "bike": "🚲", "ship": "đŸšĸ", "phone": "📱", "computer": "đŸ’ģ", "keyboard": "âŒ¨ī¸", "desktop_computer": "đŸ–Ĩī¸", "tv": "đŸ“ē", "camera": "📷", "mag": "🔍", "bulb": "💡", "flashlight": "đŸ”Ļ", "wrench": "🔧", "hammer": "🔨", "nut_and_bolt": "🔩", "gear": "âš™ī¸", "lock": "🔒", "unlock": "🔓", "key": "🔑", "bell": "🔔", "bookmark": "🔖", "link": "🔗", "bomb": "đŸ’Ŗ", "gem": "💎", "knife": "đŸ”Ē", "shield": "đŸ›Ąī¸", "trophy": "🏆", "medal": "🏅", "crown": "👑", "moneybag": "💰", "dollar": "đŸ’ĩ", "credit_card": "đŸ’ŗ", "envelope": "âœ‰ī¸", "email": "📧", "inbox_tray": "đŸ“Ĩ", "outbox_tray": "📤", "package": "đŸ“Ļ", "memo": "📝", "pencil": "âœī¸", "pencil2": "âœī¸", "book": "📖", "books": "📚", "clipboard": "📋", "calendar": "📅", "chart_with_upwards_trend": "📈", "chart_with_downwards_trend": "📉", "bar_chart": "📊", "pushpin": "📌", "paperclip": "📎", "scissors": "âœ‚ī¸", "file_folder": "📁", "open_file_folder": "📂", "wastebasket": "đŸ—‘ī¸", // Symbols "white_check_mark": "✅", "ballot_box_with_check": "â˜‘ī¸", "heavy_check_mark": "âœ”ī¸", "x": "❌", "negative_squared_cross_mark": "❎", "bangbang": "â€ŧī¸", "interrobang": "â‰ī¸", "warning": "âš ī¸", "no_entry": "⛔", "recycle": "â™ģī¸", "100": "đŸ’¯", "arrow_up": "âŦ†ī¸", "arrow_down": "âŦ‡ī¸", "arrow_left": "âŦ…ī¸", "arrow_right": "âžĄī¸", "arrow_upper_right": "â†—ī¸", "arrow_lower_right": "â†˜ī¸", "arrow_upper_left": "â†–ī¸", "arrow_lower_left": "â†™ī¸", "arrows_counterclockwise": "🔄", "hash": "#ī¸âƒŖ", "information_source": "â„šī¸", "abc": "🔤", "red_circle": "🔴", "blue_circle": "đŸ”ĩ", "large_orange_diamond": "đŸ”ļ", "large_blue_diamond": "🔷", "white_circle": "âšĒ", "black_circle": "âšĢ", // Flags "checkered_flag": "🏁", "triangular_flag_on_post": "🚩", "crossed_flags": "🎌", "flag_white": "đŸŗī¸", "flag_black": "🏴", // Celebration "tada": "🎉", "confetti_ball": "🎊", "balloon": "🎈", "birthday": "🎂", "gift": "🎁", "sparkles": "✨", "sparkler": "🎇", "fireworks": "🎆", "ribbon": "🎀", "art": "🎨", "performing_arts": "🎭", "microphone": "🎤", "headphones": "🎧", "musical_keyboard": "🎹", "guitar": "🎸", "soccer": "âšŊ", "basketball": "🏀", "football": "🏈", "baseball": "⚾", "tennis": "🎾", "golf": "â›ŗ", // Places "house": "🏠", "office": "đŸĸ", "hospital": "đŸĨ", "school": "đŸĢ", "earth_americas": "🌎", "earth_africa": "🌍", "earth_asia": "🌏", "globe_with_meridians": "🌐", "camping": "đŸ•ī¸", "mount_fuji": "đŸ—ģ", "sunrise": "🌅", "sunset": "🌇", // Clock "hourglass": "⌛", "watch": "⌚", "alarm_clock": "⏰", "stopwatch": "âąī¸", "timer_clock": "â˛ī¸", "clock": "🕐", } ) func init() { md = goldmark.New( goldmark.WithExtensions( extension.GFM, highlighting.NewHighlighting( highlighting.WithStyle("github"), ), ), goldmark.WithRendererOptions( html.WithHardWraps(), ), ) policy = bluemonday.UGCPolicy() policy.AllowAttrs("class").OnElements("code", "pre", "span", "div", "ul", "li") policy.AllowAttrs("style").OnElements("span", "pre", "code") // Allow task list checkboxes generated by goldmark GFM policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") policy.AllowAttrs("checked", "disabled").OnElements("input") } // ExtractMentions returns unique @usernames found in the raw markdown text. func ExtractMentions(texts ...string) []string { seen := map[string]bool{} var result []string for _, text := range texts { for _, m := range RawMentionRegex.FindAllStringSubmatch(text, -1) { username := m[1] if !seen[username] { seen[username] = true result = append(result, username) } } } return result } // RenderMarkdown converts markdown text to sanitized HTML. // An optional mentions map (username -> display name) can be passed to style @mentions. func RenderMarkdown(input string, mentions map[string]string) template.HTML { var buf bytes.Buffer if err := md.Convert([]byte(input), &buf); err != nil { return template.HTML(template.HTMLEscapeString(input)) } sanitized := string(policy.SanitizeBytes(buf.Bytes())) sanitized = processMermaid(sanitized) sanitized = processEmojis(sanitized) if len(mentions) > 0 { sanitized = processMentions(sanitized, mentions) } return template.HTML(sanitized) } // processMentions replaces @username in HTML text with styled spans. // It avoids replacing inside ,
, and  tags.
func processMentions(html string, mentions map[string]string) string {
	// Simple approach: split on code/pre blocks, only process outside them
	// For robustness, just do a string replacement for known usernames
	for username, displayName := range mentions {
		old := "@" + username
		title := template.HTMLEscapeString(displayName)
		replacement := fmt.Sprintf(`@%s`, title, template.HTMLEscapeString(username))
		html = replaceOutsideCode(html, old, replacement)
	}
	return html
}

// replaceOutsideCode replaces old with new in html, but skips content inside  and 
 tags.
func replaceOutsideCode(html, old, replacement string) string {
	var result strings.Builder
	i := 0
	for i < len(html) {
		// Check if we're entering a code or pre block
		if i < len(html)-1 && html[i] == '<' {
			lower := strings.ToLower(html[i:])
			if strings.HasPrefix(lower, " 0 && isWordChar(html[i-1])
			after := i+len(old) < len(html) && isWordChar(html[i+len(old)])
			if !before && !after {
				result.WriteString(replacement)
				i += len(old)
				continue
			}
		}

		result.WriteByte(html[i])
		i++
	}
	return result.String()
}

// processEmojis replaces :shortcode: patterns with Unicode emoji characters.
// It skips content inside  and 
 tags using replaceOutsideCode.
func processEmojis(html string) string {
	// Find all shortcode matches and collect unique ones that have emoji mappings
	matches := emojiRegex.FindAllString(html, -1)
	seen := map[string]bool{}
	for _, match := range matches {
		if seen[match] {
			continue
		}
		seen[match] = true
		name := match[1 : len(match)-1]
		if emoji, ok := emojiMap[name]; ok {
			html = replaceOutsideCode(html, match, emoji)
		}
	}
	return html
}

// processMermaid transforms mermaid code blocks from goldmark's rendered format
// into the format mermaid.js expects: 
...content...
func processMermaid(html string) string { return mermaidBlockRegex.ReplaceAllString(html, `
$1
`) } func isWordChar(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' }