package markdown import ( "bytes" "fmt" "path/filepath" "regexp" "strings" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" emoji "github.com/yuin/goldmark-emoji" "gobsidian/internal/config" ) type Renderer struct { md goldmark.Markdown config *config.Config } // processMermaidFences wraps fenced code blocks marked as "mermaid" into
...
func (r *Renderer) processMermaidFences(content string) string { // ```mermaid\n...\n``` re := regexp.MustCompile("(?s)```mermaid\\s*(.*?)\\s*```") return re.ReplaceAllString(content, "
$1
") } // processMediaEmbeds turns a bare URL on its own line into an embed element // Supports: audio (mp3|wav|ogg), video (mp4|webm|ogg), pdf func (r *Renderer) processMediaEmbeds(content string) string { lines := strings.Split(content, "\n") mediaRe := regexp.MustCompile(`^(https?://\S+\.(?:mp3|wav|ogg|mp4|webm|ogg|pdf))$`) for i, ln := range lines { trimmed := strings.TrimSpace(ln) m := mediaRe.FindStringSubmatch(trimmed) if len(m) == 0 { continue } url := m[1] switch { case strings.HasSuffix(strings.ToLower(url), ".mp3") || strings.HasSuffix(strings.ToLower(url), ".wav") || strings.HasSuffix(strings.ToLower(url), ".ogg"): lines[i] = fmt.Sprintf("", url) case strings.HasSuffix(strings.ToLower(url), ".mp4") || strings.HasSuffix(strings.ToLower(url), ".webm") || strings.HasSuffix(strings.ToLower(url), ".ogg"): lines[i] = fmt.Sprintf("", url) case strings.HasSuffix(strings.ToLower(url), ".pdf"): lines[i] = fmt.Sprintf("", url) } } return strings.Join(lines, "\n") } func NewRenderer(cfg *config.Config) *Renderer { md := goldmark.New( goldmark.WithExtensions( extension.GFM, extension.Table, extension.Strikethrough, extension.TaskList, extension.Footnote, extension.DefinitionList, extension.Linkify, extension.Typographer, emoji.Emoji, highlighting.NewHighlighting( highlighting.WithStyle("github-dark"), highlighting.WithFormatOptions( // Disable line numbers in code blocks for cleaner display chromahtml.WithLineNumbers(false), chromahtml.WithClasses(true), ), ), ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions( html.WithHardWraps(), html.WithXHTML(), html.WithUnsafe(), ), ) return &Renderer{ md: md, config: cfg, } } func (r *Renderer) RenderMarkdown(content string, notePath string) (string, error) { // Process Obsidian image syntax content = r.processObsidianImages(content, notePath) // Process Obsidian links content = r.processObsidianLinks(content) // Convert Mermaid fenced code blocks to
for client rendering content = r.processMermaidFences(content) // Convert bare media links on their own line to embedded players (audio/video/pdf) content = r.processMediaEmbeds(content) var buf bytes.Buffer if err := r.md.Convert([]byte(content), &buf); err != nil { return "", err } return buf.String(), nil } func (r *Renderer) processObsidianImages(content string, notePath string) string { // Regex to match Obsidian image syntax: ![[image.png]] or ![[folder/image.png]] obsidianImageRegex := regexp.MustCompile(`!\[\[([^\]]+\.(png|jpg|jpeg|gif|webp))\]\]`) return obsidianImageRegex.ReplaceAllStringFunc(content, func(match string) string { // Extract the image path from the match submatch := obsidianImageRegex.FindStringSubmatch(match) if len(submatch) < 2 { return match } imagePath := submatch[1] // Get storage info based on current note path // storageInfo := utils.GetImageStorageInfo(notePath, r.config) // Clean up the path cleanPath := filepath.Clean(imagePath) cleanPath = strings.ReplaceAll(cleanPath, "\\", "/") cleanPath = strings.Trim(cleanPath, "/") // Generate the appropriate URL based on storage mode var imageURL string switch r.config.ImageStorageMode { case 2: // Mode 2: Specific storage folder - just the filename imageURL = fmt.Sprintf("/serve_stored_image/%s", filepath.Base(cleanPath)) default: // For modes 1, 3, and 4 imageURL = fmt.Sprintf("/serve_attached_image/%s", cleanPath) } // Prefix with configured URL base if bp := r.config.URLPrefix; bp != "" { imageURL = bp + imageURL } // Convert to standard markdown image syntax alt := filepath.Base(imagePath) return fmt.Sprintf("![%s](%s)", alt, imageURL) }) } func (r *Renderer) processObsidianLinks(content string) string { // Regex to match Obsidian wiki-links: [[Note Name]] or [[Note Name|Display Text]] obsidianLinkRegex := regexp.MustCompile(`\[\[([^\]|]+)(\|([^\]]+))?\]\]`) return obsidianLinkRegex.ReplaceAllStringFunc(content, func(match string) string { submatch := obsidianLinkRegex.FindStringSubmatch(match) if len(submatch) < 2 { return match } noteName := strings.TrimSpace(submatch[1]) displayText := noteName // If there's custom display text (after |), use it if len(submatch) >= 4 && submatch[3] != "" { displayText = strings.TrimSpace(submatch[3]) } // Convert note name to URL-friendly format noteURL := strings.ReplaceAll(noteName, " ", "%20") noteURL = fmt.Sprintf("/note/%s.md", noteURL) if bp := r.config.URLPrefix; bp != "" { noteURL = bp + noteURL } // Convert to standard markdown link syntax return fmt.Sprintf("[%s](%s)", displayText, noteURL) }) }