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
59 changes: 53 additions & 6 deletions internal/markdown/codeblock.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,41 @@ import (
)

// fencedBlockRe matches fenced code blocks with svg, mermaid, or plantuml language.
var fencedBlockRe = regexp.MustCompile("(?m)^```(svg|mermaid|plantuml)\\s*\n((?s:.*?))^```\\s*$")
// Supports both standard syntax (```mermaid) and Pandoc-style attributes (```{.mermaid width=700}).
// Capture groups: (1) language, (2) attribute string, (3) code content.
var fencedBlockRe = regexp.MustCompile("(?m)^```(?:\\{\\.)?(svg|mermaid|plantuml)([^}\\n]*)\\}?\\s*\\n((?s:.*?))^```\\s*$")

var attrPairRe = regexp.MustCompile(`(\w+)=["']?([^"'\s}]*)["']?`)
var cssLengthRe = regexp.MustCompile(`^[\d.]+(px|%|em|rem|vh|vw|ch)?$`)

// PreprocessCodeBlocks replaces svg, mermaid, and plantuml fenced code blocks
// in Markdown source with raw HTML before goldmark processing.
// Returns the processed source and is safe because goldmark is configured with html.WithUnsafe().
func PreprocessCodeBlocks(source []byte, plantumlServer string) []byte {
return fencedBlockRe.ReplaceAllFunc(source, func(match []byte) []byte {
parts := fencedBlockRe.FindSubmatch(match)
if len(parts) < 3 {
if len(parts) < 4 {
return match
}
lang := string(parts[1])
code := string(parts[2])
attrs := parseAttrs(string(parts[2]))
code := string(parts[3])
style := buildStyleAttr(attrs)

switch lang {
case "svg":
// Remove blank lines from SVG content to prevent goldmark from
// splitting the HTML block (CommonMark HTML block type 6 ends at
// a blank line).
code = removeBlankLines(code)
return []byte(fmt.Sprintf("\n<div class=\"svg-container\">\n%s</div>\n", code))
return []byte(fmt.Sprintf("\n<div class=\"svg-container\"%s>\n%s</div>\n", style, code))
case "mermaid":
return []byte(fmt.Sprintf("\n<pre class=\"mermaid\">\n%s</pre>\n", code))
return []byte(fmt.Sprintf("\n<pre class=\"mermaid\"%s>\n%s</pre>\n", style, code))
case "plantuml":
if plantumlServer != "" {
encoded := encodePlantUML(code)
imgURL := fmt.Sprintf("%s/svg/%s", strings.TrimRight(plantumlServer, "/"), encoded)
return []byte(fmt.Sprintf("\n<div class=\"plantuml-container\"><img src=\"%s\" alt=\"PlantUML diagram\"></div>\n", imgURL))
return []byte(fmt.Sprintf("\n<div class=\"plantuml-container\"%s><img src=\"%s\" alt=\"PlantUML diagram\"></div>\n", style, imgURL))
}
return []byte("\n<div class=\"plantuml-notice\">" +
"<strong>PlantUML rendering is disabled.</strong> " +
Expand All @@ -47,6 +54,46 @@ func PreprocessCodeBlocks(source []byte, plantumlServer string) []byte {
})
}

// parseAttrs parses key=value pairs from a Pandoc-style attribute string.
// e.g., " width=700 height=400" → {"width": "700", "height": "400"}
func parseAttrs(s string) map[string]string {
attrs := make(map[string]string)
for _, m := range attrPairRe.FindAllStringSubmatch(s, -1) {
attrs[m[1]] = m[2]
}
return attrs
}

// sanitizeCSSLength validates and normalizes a CSS length value.
// Bare numbers (e.g. "700") are treated as pixels. Returns empty string if invalid.
func sanitizeCSSLength(v string) string {
if !cssLengthRe.MatchString(v) {
return ""
}
for _, unit := range []string{"px", "%", "em", "rem", "vh", "vw", "ch"} {
if strings.HasSuffix(v, unit) {
return v
}
}
return v + "px"
}

// buildStyleAttr returns an HTML style attribute string (e.g. ` style="max-width: 700px"`)
// from attrs. Supported keys: width, height. Returns empty string if no relevant attrs.
func buildStyleAttr(attrs map[string]string) string {
var parts []string
if w := sanitizeCSSLength(attrs["width"]); w != "" {
parts = append(parts, "max-width: "+w)
}
if h := sanitizeCSSLength(attrs["height"]); h != "" {
parts = append(parts, "max-height: "+h)
}
if len(parts) == 0 {
return ""
}
return fmt.Sprintf(` style="%s"`, strings.Join(parts, "; "))
}

// removeBlankLines removes blank lines (empty or whitespace-only) from the text.
func removeBlankLines(s string) string {
var b strings.Builder
Expand Down
108 changes: 108 additions & 0 deletions internal/markdown/codeblock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,114 @@ func TestPreprocessCodeBlocks_Mermaid(t *testing.T) {
}
}

func TestPreprocessCodeBlocks_MermaidPandocAttrs(t *testing.T) {
input := []byte("```{.mermaid width=700}\ngraph LR\n A-->B\n```\n")
result := PreprocessCodeBlocks(input, "")
s := string(result)

if !strings.Contains(s, `class="mermaid"`) {
t.Error("should render mermaid pre tag with Pandoc-style attributes")
}
if !strings.Contains(s, `max-width: 700px`) {
t.Error("should apply max-width style from width attribute")
}
if strings.Contains(s, "width=700") {
t.Error("raw attributes should not appear in output")
}
}

func TestPreprocessCodeBlocks_MermaidPandocNoAttrs(t *testing.T) {
input := []byte("```{.mermaid}\ngraph LR\n A-->B\n```\n")
result := PreprocessCodeBlocks(input, "")
s := string(result)

if !strings.Contains(s, `class="mermaid"`) {
t.Error("should render mermaid pre tag with Pandoc-style class only")
}
if strings.Contains(s, "style=") {
t.Error("should not add style attribute when no attrs specified")
}
}

func TestPreprocessCodeBlocks_MermaidWidthPx(t *testing.T) {
input := []byte("```{.mermaid width=500px}\ngraph LR\n A-->B\n```\n")
result := PreprocessCodeBlocks(input, "")
s := string(result)

if !strings.Contains(s, `max-width: 500px`) {
t.Error("should apply max-width style from width=Npx")
}
}

func TestPreprocessCodeBlocks_MermaidWidthPercent(t *testing.T) {
input := []byte("```{.mermaid width=80%}\ngraph LR\n A-->B\n```\n")
result := PreprocessCodeBlocks(input, "")
s := string(result)

if !strings.Contains(s, `max-width: 80%`) {
t.Error("should apply max-width style from width=N%")
}
}

func TestPreprocessCodeBlocks_MermaidHeightAttr(t *testing.T) {
input := []byte("```{.mermaid height=400}\ngraph LR\n A-->B\n```\n")
result := PreprocessCodeBlocks(input, "")
s := string(result)

if !strings.Contains(s, `max-height: 400px`) {
t.Error("should apply max-height style from height attribute")
}
}

func TestPreprocessCodeBlocks_MermaidWidthAndHeight(t *testing.T) {
input := []byte("```{.mermaid width=700 height=400}\ngraph LR\n A-->B\n```\n")
result := PreprocessCodeBlocks(input, "")
s := string(result)

if !strings.Contains(s, `max-width: 700px`) {
t.Error("should apply max-width from width attribute")
}
if !strings.Contains(s, `max-height: 400px`) {
t.Error("should apply max-height from height attribute")
}
}

func TestPreprocessCodeBlocks_MermaidInvalidWidth(t *testing.T) {
input := []byte("```{.mermaid width=abc}\ngraph LR\n A-->B\n```\n")
result := PreprocessCodeBlocks(input, "")
s := string(result)

if strings.Contains(s, "style=") {
t.Error("should ignore invalid width value")
}
}

func TestPreprocessCodeBlocks_SVGWithWidth(t *testing.T) {
input := []byte("```{.svg width=300}\n<svg><circle/></svg>\n```\n")
result := PreprocessCodeBlocks(input, "")
s := string(result)

if !strings.Contains(s, "svg-container") {
t.Error("should render SVG container")
}
if !strings.Contains(s, `max-width: 300px`) {
t.Error("should apply max-width style to svg-container")
}
}

func TestPreprocessCodeBlocks_PlantUMLWithWidth(t *testing.T) {
input := []byte("```{.plantuml width=600}\n@startuml\nAlice -> Bob\n@enduml\n```\n")
result := PreprocessCodeBlocks(input, "https://www.plantuml.com/plantuml")
s := string(result)

if !strings.Contains(s, "plantuml-container") {
t.Error("should render PlantUML container")
}
if !strings.Contains(s, `max-width: 600px`) {
t.Error("should apply max-width style to plantuml-container")
}
}

func TestConvert_SVGWithBlankLines(t *testing.T) {
input := []byte("# Title\n\n```svg\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\">\n\n <rect width=\"100\" height=\"100\" fill=\"#fff\"/>\n\n <text x=\"50\" y=\"50\">Hello</text>\n\n</svg>\n```\n\nParagraph after SVG.\n")
result, err := Convert(input, "")
Expand Down
Loading