diff --git a/README.md b/README.md index a732485a..dc1bb450 100644 --- a/README.md +++ b/README.md @@ -782,6 +782,11 @@ graph TD; A-->B; ``` +#### Render Mermaid Diagram in browser +Optionally you can enable mermaid diagram storing as text content and rendering in browser via `--features="mermaid-cloud"`. + +But this requires the [Mermaid Diagrams for Confluence](https://marketplace.atlassian.com/apps/1226567/mermaid-diagrams-for-confluence?hosting=cloud&tab=overview) macro to be installed in your Confluence instance. + ### Render D2 Diagram Optionally you can enable [D2](https://github.com/terrastruct/d2) rendering via `--features="d2"`. diff --git a/renderer/fencedcodeblock.go b/renderer/fencedcodeblock.go index f6e29985..b99e66b0 100644 --- a/renderer/fencedcodeblock.go +++ b/renderer/fencedcodeblock.go @@ -1,6 +1,8 @@ package renderer import ( + "crypto/sha256" + "encoding/hex" "fmt" "regexp" "slices" @@ -175,6 +177,45 @@ func (r *ConfluenceFencedCodeBlockRenderer) renderFencedCodeBlock(writer util.Bu return ast.WalkStop, err } + } else if lang == "mermaid" && slices.Contains(r.MarkConfig.Features, "mermaid-cloud") { + // Native mermaid support through the confluence plugin mermaid-cloud: upload .md attachment and use mermaid-cloud macro + // https://stratus-addons.atlassian.net/wiki/spaces/MDFC/pages/3088416802/Quickstart + diagramName := title + if diagramName == "" { + // Generate unique name from content hash to avoid collisions + // when multiple untitled mermaid diagrams exist on one page + hash := sha256.Sum256(lval) + diagramName = "mermaid-" + hex.EncodeToString(hash[:])[:8] + } + att := attachment.Attachment{ + Name: diagramName, + Filename: diagramName + ".md", + FileBytes: lval, + Replace: diagramName, + Checksum: "", // will be computed by ResolveAttachments, + } + r.Attachments.Attach(att) + + err := r.Stdlib.Templates.ExecuteTemplate( + writer, + "ac:mermaid-cloud", + struct { + Filename string + Format string + Zoom string + Toolbar string + }{ + att.Filename, + "text/plain", + "fit", + "bottom", + }, + ) + + if err != nil { + return ast.WalkStop, err + } + } else if lang == "mermaid" && slices.Contains(r.MarkConfig.Features, "mermaid") { attachment, err := mermaid.ProcessMermaidLocally(title, lval, r.MarkConfig.MermaidScale) if err != nil { diff --git a/renderer/fencedcodeblock_test.go b/renderer/fencedcodeblock_test.go new file mode 100644 index 00000000..1515c705 --- /dev/null +++ b/renderer/fencedcodeblock_test.go @@ -0,0 +1,190 @@ +package renderer + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "github.com/kovetskiy/mark/v16/attachment" + "github.com/kovetskiy/mark/v16/stdlib" + "github.com/kovetskiy/mark/v16/types" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark" + goldmarkRenderer "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" +) + +// newGoldmarkWithRenderer creates a goldmark instance with the given fenced code block renderer +func newGoldmarkWithRenderer(fcbRenderer goldmarkRenderer.NodeRenderer) goldmark.Markdown { + m := goldmark.New( + goldmark.WithRendererOptions(html.WithUnsafe(), html.WithXHTML()), + ) + m.Renderer().AddOptions(goldmarkRenderer.WithNodeRenderers( + util.Prioritized(fcbRenderer, 100), + )) + return m +} + +// mockAttacher collects attachments for testing +type mockAttacher struct { + attachments []attachment.Attachment +} + +func (m *mockAttacher) Attach(a attachment.Attachment) { + m.attachments = append(m.attachments, a) +} + +func TestMermaidNative_WithTitle(t *testing.T) { + lib, err := stdlib.New(nil) + require.NoError(t, err) + + attacher := &mockAttacher{} + cfg := types.MarkConfig{ + Features: []string{"mermaid-cloud"}, + } + + renderer := NewConfluenceFencedCodeBlockRenderer(lib, attacher, cfg) + + // Simulate a fenced code block with mermaid and title + source := []byte("```mermaid title My Diagram\ngraph TD;\n A-->B;\n```\n") + // Use goldmark to parse and render + md := newGoldmarkWithRenderer(renderer) + var buf bytes.Buffer + err = md.Convert(source, &buf) + require.NoError(t, err) + + output := buf.String() + + // Verify mermaid-cloud macro is generated + assert.Contains(t, output, `My Diagram.md`) + assert.Contains(t, output, `svg`) + assert.Contains(t, output, `fit`) + assert.Contains(t, output, `bottom`) + + // Verify attachment was created + require.Len(t, attacher.attachments, 1) + assert.Equal(t, "My Diagram", attacher.attachments[0].Name) + assert.Equal(t, "My Diagram.md", attacher.attachments[0].Filename) + assert.Equal(t, "graph TD;\n A-->B;\n", string(attacher.attachments[0].FileBytes)) +} + +func TestMermaidNative_WithoutTitle(t *testing.T) { + lib, err := stdlib.New(nil) + require.NoError(t, err) + + attacher := &mockAttacher{} + cfg := types.MarkConfig{ + Features: []string{"mermaid-cloud"}, + } + + r := NewConfluenceFencedCodeBlockRenderer(lib, attacher, cfg) + + diagramContent := []byte("graph TD;\n C-->D;\n") + // Compute expected hash + hash := sha256.Sum256(diagramContent) + expectedName := "mermaid-" + hex.EncodeToString(hash[:])[:8] + expectedFilename := expectedName + ".md" + + source := []byte("```mermaid\ngraph TD;\n C-->D;\n```\n") + md := newGoldmarkWithRenderer(r) + var buf bytes.Buffer + err = md.Convert(source, &buf) + require.NoError(t, err) + + output := buf.String() + + // Verify mermaid-cloud macro uses hash-based filename + assert.Contains(t, output, ``+expectedFilename+``) + + // Verify attachment was created with hash-based name + require.Len(t, attacher.attachments, 1) + assert.Equal(t, expectedName, attacher.attachments[0].Name) + assert.Equal(t, expectedFilename, attacher.attachments[0].Filename) +} + +func TestMermaidNative_MultipleUntitled(t *testing.T) { + lib, err := stdlib.New(nil) + require.NoError(t, err) + + attacher := &mockAttacher{} + cfg := types.MarkConfig{ + Features: []string{"mermaid-cloud"}, + } + + r := NewConfluenceFencedCodeBlockRenderer(lib, attacher, cfg) + + // Two different untitled diagrams + source := []byte("```mermaid\ngraph TD;\n A-->B;\n```\n\n```mermaid\ngraph TD;\n C-->D;\n```\n") + md := newGoldmarkWithRenderer(r) + var buf bytes.Buffer + err = md.Convert(source, &buf) + require.NoError(t, err) + + // Verify two attachments were created with different names + require.Len(t, attacher.attachments, 2) + assert.NotEqual(t, attacher.attachments[0].Name, attacher.attachments[1].Name, + "Multiple untitled mermaid diagrams should have unique hash-based names") + assert.NotEqual(t, attacher.attachments[0].Filename, attacher.attachments[1].Filename) + + // Verify both are .md files + assert.True(t, strings.HasSuffix(attacher.attachments[0].Filename, ".md")) + assert.True(t, strings.HasSuffix(attacher.attachments[1].Filename, ".md")) +} + +func TestMermaidNative_SameContentSameHash(t *testing.T) { + lib, err := stdlib.New(nil) + require.NoError(t, err) + + attacher := &mockAttacher{} + cfg := types.MarkConfig{ + Features: []string{"mermaid-cloud"}, + } + + r := NewConfluenceFencedCodeBlockRenderer(lib, attacher, cfg) + + // Two identical untitled diagrams should produce the same hash + source := []byte("```mermaid\ngraph TD;\n A-->B;\n```\n\n```mermaid\ngraph TD;\n A-->B;\n```\n") + md := newGoldmarkWithRenderer(r) + var buf bytes.Buffer + err = md.Convert(source, &buf) + require.NoError(t, err) + + // Same content = same hash = same name (content-addressable) + require.Len(t, attacher.attachments, 2) + assert.Equal(t, attacher.attachments[0].Name, attacher.attachments[1].Name, + "Identical untitled mermaid diagrams should produce the same hash-based name") +} + +func TestMermaidNative_NotActiveWhenFeatureDisabled(t *testing.T) { + lib, err := stdlib.New(nil) + require.NoError(t, err) + + attacher := &mockAttacher{} + // Only "mermaid" feature, NOT "mermaid-cloud" + cfg := types.MarkConfig{ + Features: []string{"mermaid"}, + } + + r := NewConfluenceFencedCodeBlockRenderer(lib, attacher, cfg) + + source := []byte("```mermaid\ngraph TD;\n A-->B;\n```\n") + md := newGoldmarkWithRenderer(r) + var buf bytes.Buffer + // This will fail because mermaid.ProcessMermaidLocally requires chrome, + // but the key point is that it does NOT produce mermaid-cloud macro + err = md.Convert(source, &buf) + + // If chrome is not available, the mermaid branch will error. + // But we should NOT see mermaid-cloud in the output + if err == nil { + assert.NotContains(t, buf.String(), `ac:name="mermaid-cloud"`, + "mermaid-cloud macro should NOT be generated when mermaid-cloud feature is disabled") + } +} \ No newline at end of file diff --git a/stdlib/stdlib.go b/stdlib/stdlib.go index eff8b697..d4e52920 100644 --- a/stdlib/stdlib.go +++ b/stdlib/stdlib.go @@ -440,6 +440,17 @@ func templates(api *confluence.API) (*template.Template, error) { ``, ), + /* Mermaid Diagrams for Confluence (mermaid-cloud macro) */ + /* https://marketplace.atlassian.com/apps/1226567/mermaid-diagrams-for-confluence?hosting=cloud&tab=overview */ + `ac:mermaid-cloud`: text( + ``, + `{{ .Filename | xmlesc }}`, + `{{ or .Format "text/plain" }}`, + `{{ or .Zoom "fit" }}`, + `{{ or .Toolbar "bottom" }}`, + ``, + ), + `ac:plantuml`: text( ``, ``, diff --git a/util/flags.go b/util/flags.go index 2a1871d6..6316be8d 100644 --- a/util/flags.go +++ b/util/flags.go @@ -210,7 +210,7 @@ var Flags = []cli.Flag{ &cli.StringSliceFlag{ Name: "features", Value: []string{"mermaid", "mention"}, - Usage: "Enables optional features. Current features: d2, mermaid, mention, mkdocsadmonitions, plantuml", + Usage: "Enables optional features. Current features: d2, mermaid, mermaid-cloud, mention, mkdocsadmonitions, plantuml", Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_FEATURES"), altsrctoml.TOML("features", altsrc.NewStringPtrSourcer(&filename))), }, &cli.BoolFlag{