diff --git a/internal/markdown/codeblock.go b/internal/markdown/codeblock.go index 76ac922..fba339e 100644 --- a/internal/markdown/codeblock.go +++ b/internal/markdown/codeblock.go @@ -8,7 +8,12 @@ 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. @@ -16,11 +21,13 @@ var fencedBlockRe = regexp.MustCompile("(?m)^```(svg|mermaid|plantuml)\\s*\n((?s 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": @@ -28,14 +35,14 @@ func PreprocessCodeBlocks(source []byte, plantumlServer string) []byte { // splitting the HTML block (CommonMark HTML block type 6 ends at // a blank line). code = removeBlankLines(code) - return []byte(fmt.Sprintf("\n
\n%s
\n", code)) + return []byte(fmt.Sprintf("\n
\n%s
\n", style, code)) case "mermaid": - return []byte(fmt.Sprintf("\n
\n%s
\n", code)) + return []byte(fmt.Sprintf("\n
\n%s
\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
\"PlantUML
\n", imgURL)) + return []byte(fmt.Sprintf("\n
\"PlantUML
\n", style, imgURL)) } return []byte("\n
" + "PlantUML rendering is disabled. " + @@ -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 diff --git a/internal/markdown/codeblock_test.go b/internal/markdown/codeblock_test.go index 8a0ca6f..336cc76 100644 --- a/internal/markdown/codeblock_test.go +++ b/internal/markdown/codeblock_test.go @@ -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\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\n\n \n\n Hello\n\n\n```\n\nParagraph after SVG.\n") result, err := Convert(input, "")