Skip to content
Open
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand Down
41 changes: 41 additions & 0 deletions renderer/fencedcodeblock.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package renderer

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"slices"
Expand Down Expand Up @@ -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 {
Expand Down
190 changes: 190 additions & 0 deletions renderer/fencedcodeblock_test.go
Original file line number Diff line number Diff line change
@@ -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, `<ac:structured-macro ac:name="mermaid-cloud"`)
assert.Contains(t, output, `<ac:parameter ac:name="filename">My Diagram.md</ac:parameter>`)
assert.Contains(t, output, `<ac:parameter ac:name="format">svg</ac:parameter>`)
assert.Contains(t, output, `<ac:parameter ac:name="zoom">fit</ac:parameter>`)
assert.Contains(t, output, `<ac:parameter ac:name="toolbar">bottom</ac:parameter>`)

// 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, `<ac:structured-macro ac:name="mermaid-cloud"`)
assert.Contains(t, output, `<ac:parameter ac:name="filename">`+expectedFilename+`</ac:parameter>`)

// 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")
}
}
11 changes: 11 additions & 0 deletions stdlib/stdlib.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,17 @@ func templates(api *confluence.API) (*template.Template, error) {
`</ac:structured-macro>`,
),

/* 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(
`<ac:structured-macro ac:name="mermaid-cloud" ac:schema-version="1">`,
`<ac:parameter ac:name="filename">{{ .Filename | xmlesc }}</ac:parameter>`,
`<ac:parameter ac:name="format">{{ or .Format "text/plain" }}</ac:parameter>`,
`<ac:parameter ac:name="zoom">{{ or .Zoom "fit" }}</ac:parameter>`,
`<ac:parameter ac:name="toolbar">{{ or .Toolbar "bottom" }}</ac:parameter>`,
`</ac:structured-macro>`,
),

`ac:plantuml`: text(
`<ac:structured-macro ac:name="plantuml">`,
`<ac:plain-text-body><![CDATA[{{ .Text | cdata }}]]></ac:plain-text-body>`,
Expand Down
2 changes: 1 addition & 1 deletion util/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down