diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go index bd72a18..b7fe25e 100644 --- a/internal/markdown/markdown.go +++ b/internal/markdown/markdown.go @@ -23,6 +23,339 @@ var ( // 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() { @@ -71,6 +404,9 @@ func RenderMarkdown(input string, mentions map[string]string) template.HTML { } sanitized := string(policy.SanitizeBytes(buf.Bytes())) + sanitized = processMermaid(sanitized) + sanitized = processEmojis(sanitized) + if len(mentions) > 0 { sanitized = processMentions(sanitized, mentions) } @@ -136,6 +472,31 @@ func replaceOutsideCode(html, old, replacement string) string { 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 == '_' } diff --git a/web/static/css/input.css b/web/static/css/input.css index a222e0a..0c44115 100644 --- a/web/static/css/input.css +++ b/web/static/css/input.css @@ -26,6 +26,14 @@ content: none; } +/* Mermaid diagram styling */ +pre.mermaid { + text-align: center; + background: transparent; + border: none; + overflow-x: auto; +} + /* Task list checkbox styling */ .prose input[type="checkbox"] { margin-right: 0.375rem; diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html index 1c18e90..6d675c1 100644 --- a/web/templates/layouts/base.html +++ b/web/templates/layouts/base.html @@ -14,5 +14,12 @@ {{block "content" .}}{{end}} +