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
\n", imgURL))
+ return []byte(fmt.Sprintf("\n
\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\nParagraph after SVG.\n")
result, err := Convert(input, "")