Repository: kovetskiy/mark
Pull request #288 (2023-04-24 18:49:24)

Add support for mermaid via library
This MR is based on and will replace: https://github.com/kovetskiy/mark/pull/167
Kudos to @dreampuf for the initial implementation

* Adding Mermaid support via mermaid.go, allowing to render it locally.
* Adding new optional arguments to ac:image namely: Title, Heigth attributes
* Switching docker image base to headless-shell


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b7ac080..af61d3c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ on:
     - master
 
 env:
-  GO_VERSION: "~1.20.2"
+  GO_VERSION: "~1.20.3"
 
 jobs:
   # Runs Golangci-lint on the source code
diff --git a/Dockerfile b/Dockerfile
index 0184ce5..b8cea44 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,15 @@
-FROM golang:1.20.2 as builder
+FROM golang:1.20.3 as builder
 ENV GOPATH="/go"
 WORKDIR /go/src/github.com/kovetskiy/mark
 COPY / .
 RUN make get \
 && make build
 
-FROM alpine:3.17
-RUN apk --no-cache add ca-certificates bash sed git
+FROM chromedp/headless-shell:latest
+RUN apt-get update \
+&& apt-get install --no-install-recommends -qq ca-certificates bash sed git \
+&& apt-get clean \
+&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
+
 COPY --from=builder /go/src/github.com/kovetskiy/mark/mark /bin/
 WORKDIR /docs
diff --git a/README.md b/README.md
index 486a87d..3a57834 100644
--- a/README.md
+++ b/README.md
@@ -553,6 +553,21 @@ And attach any image with the following
 The width will be the commented html after the image (in this case 300px).
 
 
+### Render Mermaid Diagram
+
+Confluence doesn't provide [mermaid.js](https://github.com/mermaid-js/mermaid) support natively. Mark provides a convenient way to enable the feature like [Github does](https://github.blog/2022-02-14-include-diagrams-markdown-files-mermaid/).
+As long as you have a code block and are marked as "mermaid", the mark will automatically render it as a PNG image and insert into before the code block.
+
+    ```mermaid title diagrams_example
+    graph TD;
+    A-->B;
+    ```
+
+In order to properly render mermaid, you can choose between the following mermaid providers:
+
+* "mermaid-go" via [mermaid.go](https://github.com/dreampuf/mermaid.go)
+* "cloudscript" via [cloudscript-io-mermaid-addon](https://marketplace.atlassian.com/apps/1219878/cloudscript-io-mermaid-addon)
+
 ## Installation
 
 ### Homebrew
diff --git a/go.mod b/go.mod
index b33191a..a6701e8 100644
--- a/go.mod
+++ b/go.mod
@@ -3,27 +3,37 @@ module github.com/kovetskiy/mark
 go 1.19
 
 require (
+	github.com/dreampuf/mermaid.go v0.0.4-0.20220314184516-44d4f4fc9d39
 	github.com/kovetskiy/gopencils v0.0.0-20230119081704-a73db75b2f69
 	github.com/kovetskiy/lorg v1.2.0
-	github.com/reconquest/karma-go v0.0.0-20220904173930-21741aa386a6
+	github.com/reconquest/karma-go v0.0.0-20230425053540-765a8ab89f64
 	github.com/reconquest/pkg v1.3.0
 	github.com/reconquest/regexputil-go v0.0.0-20160905154124-38573e70c1f4
 	github.com/stretchr/testify v1.8.2
 	github.com/urfave/cli/v2 v2.25.1
 	github.com/yuin/goldmark v1.5.4
-	golang.org/x/tools v0.7.0
+	golang.org/x/tools v0.8.0
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
 	github.com/BurntSushi/toml v1.2.1 // indirect
+	github.com/chromedp/cdproto v0.0.0-20230329100754-6125fc8d7142 // indirect
+	github.com/chromedp/chromedp v0.9.1 // indirect
+	github.com/chromedp/sysutil v1.0.0 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/kr/pretty v0.1.0 // indirect
+	github.com/gobwas/httphead v0.1.0 // indirect
+	github.com/gobwas/pool v0.2.1 // indirect
+	github.com/gobwas/ws v1.1.0 // indirect
+	github.com/josharian/intern v1.0.0 // indirect
+	github.com/kr/pretty v0.3.1 // indirect
+	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/reconquest/cog v0.0.0-20210820140837-c5c4e8f49c65 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
 	github.com/zazab/zhash v0.0.0-20210630080733-6e809466f8d3 // indirect
+	golang.org/x/sys v0.7.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 )
diff --git a/go.sum b/go.sum
index d73146b..51c9764 100644
--- a/go.sum
+++ b/go.sum
@@ -1,32 +1,58 @@
 github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
 github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
+github.com/chromedp/cdproto v0.0.0-20230329100754-6125fc8d7142 h1:7H1PudqT2SgX/U2ZwPOBOvj75Jnoks+eYVvEEFpi7h0=
+github.com/chromedp/cdproto v0.0.0-20230329100754-6125fc8d7142/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
+github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA=
+github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5DTScENWjaQ=
+github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
+github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
 github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
 github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dreampuf/mermaid.go v0.0.4-0.20220314184516-44d4f4fc9d39 h1:oeOSCOh+XHblSoj/FsR0X/BOT6f3oj0OE58Y2tPoISM=
+github.com/dreampuf/mermaid.go v0.0.4-0.20220314184516-44d4f4fc9d39/go.mod h1:e9BYes2y5OhMb70VsPioP2vksPofJQ40ywhFwtmQ45w=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
+github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 github.com/kovetskiy/gopencils v0.0.0-20230119081704-a73db75b2f69 h1:vn82v0gKhTTm67znr7nxYBNW4mJ8zfY7dywZivUy3tY=
 github.com/kovetskiy/gopencils v0.0.0-20230119081704-a73db75b2f69/go.mod h1:t7LFI5v8Q5+nl9sqId9PS0C9H9F4c5d4XlhkLve1MCM=
 github.com/kovetskiy/lorg v0.0.0-20200107130803-9a7136a95634/go.mod h1:B8HeKAukXULNzWWsW5k/SQyDkiQZPn7lTBJDB46MZ9I=
 github.com/kovetskiy/lorg v1.2.0 h1:wNIUT/VOhcjKOmizDClZLvchbKFGW+dzf9fQXbSVS5E=
 github.com/kovetskiy/lorg v1.2.0/go.mod h1:rdiamaIRUCkX9HtFZd0D9dQqUbad21hipHk+sat7Z6s=
-github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/reconquest/cog v0.0.0-20210820140837-c5c4e8f49c65 h1:IiAAeijD0sU3C6OO9vy/0WUUfRabZ1aH7hijCBteJC4=
 github.com/reconquest/cog v0.0.0-20210820140837-c5c4e8f49c65/go.mod h1:iin2k2yhKESAy14B2fXK8gpf1nofl7dTXH5U+VdIlss=
 github.com/reconquest/karma-go v0.0.0-20200928103525-22da92476de6/go.mod h1:yuQiKpTdmXSX7E+h+3dD4jx09P/gHc67mRxN3eFLt7o=
-github.com/reconquest/karma-go v0.0.0-20220904173930-21741aa386a6 h1:wSI9nn6ZDtuA4Coi6oWmhBqSHGLUK2XRhn9x/4QCCMY=
-github.com/reconquest/karma-go v0.0.0-20220904173930-21741aa386a6/go.mod h1:qMQ8twYxBpCJ4IYrAnMDBtdNMj0ZTH+kcu94ZIY6vDU=
+github.com/reconquest/karma-go v0.0.0-20230425053540-765a8ab89f64 h1:1ftdBojSr4bnBpe3UkSGG40fI70g5Av3xLQKn9Gkmx4=
+github.com/reconquest/karma-go v0.0.0-20230425053540-765a8ab89f64/go.mod h1:52XRXXa2ec/VNrlCirwasdJfNmjI1O87q098gmqILh0=
 github.com/reconquest/pkg v1.3.0 h1:Yuoxiw92rP/srKXMo5qSML2InhJ+xAqHJIx3/y/2zh8=
 github.com/reconquest/pkg v1.3.0/go.mod h1:hUQ0SzzBlFRSbo6lFYG2tSpLMjqOuUqm2LtpjR/+1sg=
 github.com/reconquest/regexputil-go v0.0.0-20160905154124-38573e70c1f4 h1:bcDXaTFC09IIg13Z8gfQHk4gSu001ET7ssW/wKRvPzg=
 github.com/reconquest/regexputil-go v0.0.0-20160905154124-38573e70c1f4/go.mod h1:OI1di2iiFSwX3D70iZjzdmCPPfssjOl+HX40tI3VaXA=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -45,8 +71,12 @@ github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
 github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/zazab/zhash v0.0.0-20210630080733-6e809466f8d3 h1:BhVaeQJc3xalHGONn215FylzuxdQBIT3d/aRjDg4nXQ=
 github.com/zazab/zhash v0.0.0-20210630080733-6e809466f8d3/go.mod h1:NtepZ8TEXErPsmQDMUoN72f8aIy4+xNinSJ3f1giess=
-golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4=
-golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
+golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
+golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
+golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/main.go b/main.go
index 7ac84aa..3e93fcc 100644
--- a/main.go
+++ b/main.go
@@ -13,6 +13,7 @@ import (
 	"github.com/kovetskiy/mark/pkg/mark/includes"
 	"github.com/kovetskiy/mark/pkg/mark/macro"
 	"github.com/kovetskiy/mark/pkg/mark/stdlib"
+	"github.com/kovetskiy/mark/pkg/mark/vfs"
 	"github.com/reconquest/karma-go"
 	"github.com/reconquest/pkg/log"
 	"github.com/urfave/cli/v2"
@@ -22,8 +23,7 @@ import (
 const (
 	version     = "9.1.4"
 	usage       = "A tool for updating Atlassian Confluence pages from markdown."
-	description = `Mark is a tool to update Atlassian Confluence pages from markdown. Documentation is available here: https://github.com/kovetskiy/mark
-`
+	description = `Mark is a tool to update Atlassian Confluence pages from markdown. Documentation is available here: https://github.com/kovetskiy/mark`
 )
 
 var flags = []cli.Flag{
@@ -139,6 +139,12 @@ var flags = []cli.Flag{
 		Usage:   "use specified space key. If the space key is not specified, it must be set in the page metadata.",
 		EnvVars: []string{"MARK_SPACE"},
 	}),
+	altsrc.NewStringFlag(&cli.StringFlag{
+		Name:    "mermaid-provider",
+		Value:   "cloudscript",
+		Usage:   "defines the mermaid provider to use. Supported options are: cloudscript, mermaid-go.",
+		EnvVars: []string{"MARK_MERMAID_PROVIDER"},
+	}),
 }
 
 func main() {
@@ -347,7 +353,8 @@ func processFile(
 			markdown = mark.DropDocumentLeadingH1(markdown)
 		}
 
-		fmt.Println(mark.CompileMarkdown(markdown, stdlib))
+		html, _ := mark.CompileMarkdown(markdown, stdlib, cCtx.String("mermaid-provider"))
+		fmt.Println(html)
 		os.Exit(0)
 	}
 
@@ -399,11 +406,16 @@ func processFile(
 		target = page
 	}
 
+	// Resolve attachments created from <!-- Attachment: --> directive
+	localAttachments, err := mark.ResolveLocalAttachments(vfs.LocalOS, filepath.Dir(file), meta.Attachments)
+	if err != nil {
+		log.Fatalf(err, "unable to locate attachments")
+	}
+
 	attaches, err := mark.ResolveAttachments(
 		api,
 		target,
-		filepath.Dir(file),
-		meta.Attachments,
+		localAttachments,
 	)
 	if err != nil {
 		log.Fatalf(err, "unable to create/update attachments")
@@ -418,7 +430,17 @@ func processFile(
 		markdown = mark.DropDocumentLeadingH1(markdown)
 	}
 
-	html := mark.CompileMarkdown(markdown, stdlib)
+	html, inlineAttachments := mark.CompileMarkdown(markdown, stdlib, cCtx.String("mermaid-provider"))
+
+	// Resolve attachements detected from markdown
+	_, err = mark.ResolveAttachments(
+		api,
+		target,
+		inlineAttachments,
+	)
+	if err != nil {
+		log.Fatalf(err, "unable to create/update attachments")
+	}
 
 	{
 		var buffer bytes.Buffer
diff --git a/pkg/confluence/api.go b/pkg/confluence/api.go
index d4d7be4..5c2e5a4 100644
--- a/pkg/confluence/api.go
+++ b/pkg/confluence/api.go
@@ -8,7 +8,6 @@ import (
 	"io"
 	"mime/multipart"
 	"net/http"
-	"os"
 	"strings"
 
 	"github.com/kovetskiy/gopencils"
@@ -52,7 +51,7 @@ type PageInfo struct {
 	} `json:"version"`
 
 	Ancestors []struct {
-		Id    string `json:"id"`
+		ID    string `json:"id"`
 		Title string `json:"title"`
 	} `json:"ancestors"`
 
@@ -141,7 +140,7 @@ func (api *API) FindRootPage(space string) (*PageInfo, error) {
 	}
 
 	return &PageInfo{
-		ID:    page.Ancestors[0].Id,
+		ID:    page.Ancestors[0].ID,
 		Title: page.Ancestors[0].Title,
 	}, nil
 }
@@ -158,7 +157,7 @@ func (api *API) FindHomePage(space string) (*PageInfo, error) {
 		return nil, err
 	}
 
-	if request.Raw.StatusCode == 404 || request.Raw.StatusCode != 200 {
+	if request.Raw.StatusCode == http.StatusNotFound || request.Raw.StatusCode != http.StatusOK {
 		return nil, newErrorStatusNotOK(request)
 	}
 
@@ -193,7 +192,7 @@ func (api *API) FindPage(
 
 	// allow 404 because it's fine if page is not found,
 	// the function will return nil, nil
-	if request.Raw.StatusCode != 404 && request.Raw.StatusCode != 200 {
+	if request.Raw.StatusCode != http.StatusNotFound && request.Raw.StatusCode != http.StatusOK {
 		return nil, newErrorStatusNotOK(request)
 	}
 
@@ -208,11 +207,11 @@ func (api *API) CreateAttachment(
 	pageID string,
 	name string,
 	comment string,
-	path string,
+	reader io.Reader,
 ) (AttachmentInfo, error) {
 	var info AttachmentInfo
 
-	form, err := getAttachmentPayload(name, comment, path)
+	form, err := getAttachmentPayload(name, comment, reader)
 	if err != nil {
 		return AttachmentInfo{}, err
 	}
@@ -243,7 +242,7 @@ func (api *API) CreateAttachment(
 		return info, err
 	}
 
-	if request.Raw.StatusCode != 200 {
+	if request.Raw.StatusCode != http.StatusOK {
 		return info, newErrorStatusNotOK(request)
 	}
 
@@ -276,11 +275,11 @@ func (api *API) UpdateAttachment(
 	attachID string,
 	name string,
 	comment string,
-	path string,
+	reader io.Reader,
 ) (AttachmentInfo, error) {
 	var info AttachmentInfo
 
-	form, err := getAttachmentPayload(name, comment, path)
+	form, err := getAttachmentPayload(name, comment, reader)
 	if err != nil {
 		return AttachmentInfo{}, err
 	}
@@ -313,7 +312,7 @@ func (api *API) UpdateAttachment(
 		return info, err
 	}
 
-	if request.Raw.StatusCode != 200 {
+	if request.Raw.StatusCode != http.StatusOK {
 		return info, newErrorStatusNotOK(request)
 	}
 
@@ -353,23 +352,12 @@ func (api *API) UpdateAttachment(
 	return shortResponse, nil
 }
 
-func getAttachmentPayload(name, comment, path string) (*form, error) {
+func getAttachmentPayload(name, comment string, reader io.Reader) (*form, error) {
 	var (
 		payload = bytes.NewBuffer(nil)
 		writer  = multipart.NewWriter(payload)
 	)
 
-	file, err := os.Open(path)
-	if err != nil {
-		return nil, karma.Format(
-			err,
-			"unable to open file: %q",
-			path,
-		)
-	}
-
-	defer file.Close()
-
 	content, err := writer.CreateFormFile("file", name)
 	if err != nil {
 		return nil, karma.Format(
@@ -378,7 +366,7 @@ func getAttachmentPayload(name, comment, path string) (*form, error) {
 		)
 	}
 
-	_, err = io.Copy(content, file)
+	_, err = io.Copy(content, reader)
 	if err != nil {
 		return nil, karma.Format(
 			err,
@@ -436,7 +424,7 @@ func (api *API) GetAttachments(pageID string) ([]AttachmentInfo, error) {
 		return nil, err
 	}
 
-	if request.Raw.StatusCode != 200 {
+	if request.Raw.StatusCode != http.StatusOK {
 		return nil, newErrorStatusNotOK(request)
 	}
 
@@ -459,7 +447,7 @@ func (api *API) GetPageByID(pageID string) (*PageInfo, error) {
 		return nil, err
 	}
 
-	if request.Raw.StatusCode != 200 {
+	if request.Raw.StatusCode != http.StatusOK {
 		return nil, newErrorStatusNotOK(request)
 	}
 
@@ -507,7 +495,7 @@ func (api *API) CreatePage(
 		return nil, err
 	}
 
-	if request.Raw.StatusCode != 200 {
+	if request.Raw.StatusCode != http.StatusOK {
 		return nil, newErrorStatusNotOK(request)
 	}
 
@@ -521,7 +509,7 @@ func (api *API) UpdatePage(page *PageInfo, newContent string, minorEdit bool, ne
 	if page.Type != "blogpost" && len(page.Ancestors) > 0 {
 		// picking only the last one, which is required by confluence
 		oldAncestors = []map[string]interface{}{
-			{"id": page.Ancestors[len(page.Ancestors)-1].Id},
+			{"id": page.Ancestors[len(page.Ancestors)-1].ID},
 		}
 	}
 
@@ -547,7 +535,7 @@ func (api *API) UpdatePage(page *PageInfo, newContent string, minorEdit bool, ne
 		"ancestors": oldAncestors,
 		"body": map[string]interface{}{
 			"storage": map[string]interface{}{
-				"value":          string(newContent),
+				"value":          newContent,
 				"representation": "storage",
 			},
 		},
@@ -573,7 +561,7 @@ func (api *API) UpdatePage(page *PageInfo, newContent string, minorEdit bool, ne
 		return err
 	}
 
-	if request.Raw.StatusCode != 200 {
+	if request.Raw.StatusCode != http.StatusOK {
 		return newErrorStatusNotOK(request)
 	}
 
@@ -654,7 +642,7 @@ func (api *API) RestrictPageUpdatesCloud(
 		return err
 	}
 
-	if request.Raw.StatusCode != 200 {
+	if request.Raw.StatusCode != http.StatusOK {
 		return newErrorStatusNotOK(request)
 	}
 
@@ -685,7 +673,7 @@ func (api *API) RestrictPageUpdatesServer(
 		return err
 	}
 
-	if request.Raw.StatusCode != 200 {
+	if request.Raw.StatusCode != http.StatusOK {
 		return newErrorStatusNotOK(request)
 	}
 
@@ -715,13 +703,13 @@ func (api *API) RestrictPageUpdates(
 }
 
 func newErrorStatusNotOK(request *gopencils.Resource) error {
-	if request.Raw.StatusCode == 401 {
+	if request.Raw.StatusCode == http.StatusUnauthorized {
 		return errors.New(
 			"Confluence API returned unexpected status: 401 (Unauthorized)",
 		)
 	}
 
-	if request.Raw.StatusCode == 404 {
+	if request.Raw.StatusCode == http.StatusNotFound {
 		return errors.New(
 			"Confluence API returned unexpected status: 404 (Not Found)",
 		)
diff --git a/pkg/mark/attachment.go b/pkg/mark/attachment.go
index ef670d2..8f4ad7e 100644
--- a/pkg/mark/attachment.go
+++ b/pkg/mark/attachment.go
@@ -6,13 +6,13 @@ import (
 	"encoding/hex"
 	"io"
 	"net/url"
-	"os"
 	"path"
 	"path/filepath"
 	"sort"
 	"strings"
 
 	"github.com/kovetskiy/mark/pkg/confluence"
+	"github.com/kovetskiy/mark/pkg/mark/vfs"
 	"github.com/reconquest/karma-go"
 	"github.com/reconquest/pkg/log"
 )
@@ -22,36 +22,32 @@ const (
 )
 
 type Attachment struct {
-	ID       string
-	Name     string
-	Filename string
-	Path     string
-	Checksum string
-	Link     string
-	Replace  string
+	ID        string
+	Name      string
+	Filename  string
+	FileBytes []byte
+	Checksum  string
+	Link      string
+	Width     string
+	Height    string
+	Replace   string
 }
 
 func ResolveAttachments(
 	api *confluence.API,
 	page *confluence.PageInfo,
-	base string,
-	replacements []string,
+	attachments []Attachment,
 ) ([]Attachment, error) {
-	attaches, err := prepareAttachments(base, replacements)
-	if err != nil {
-		return nil, err
-	}
-
-	for i := range attaches {
-		checksum, err := getChecksum(attaches[i].Path)
+	for i := range attachments {
+		checksum, err := GetChecksum(bytes.NewReader(attachments[i].FileBytes))
 		if err != nil {
 			return nil, karma.Format(
 				err,
-				"unable to get checksum for attachment: %q", attaches[i].Name,
+				"unable to get checksum for attachment: %q", attachments[i].Name,
 			)
 		}
 
-		attaches[i].Checksum = checksum
+		attachments[i].Checksum = checksum
 	}
 
 	remotes, err := api.GetAttachments(page.ID)
@@ -62,18 +58,18 @@ func ResolveAttachments(
 	existing := []Attachment{}
 	creating := []Attachment{}
 	updating := []Attachment{}
-	for _, attach := range attaches {
+	for _, attachment := range attachments {
 		var found bool
 		var same bool
 		for _, remote := range remotes {
-			if remote.Filename == attach.Filename {
-				same = attach.Checksum == strings.TrimPrefix(
+			if remote.Filename == attachment.Filename {
+				same = attachment.Checksum == strings.TrimPrefix(
 					remote.Metadata.Comment,
 					AttachmentChecksumPrefix,
 				)
 
-				attach.ID = remote.ID
-				attach.Link = path.Join(
+				attachment.ID = remote.ID
+				attachment.Link = path.Join(
 					remote.Links.Context,
 					remote.Links.Download,
 				)
@@ -86,109 +82,143 @@ func ResolveAttachments(
 
 		if found {
 			if same {
-				existing = append(existing, attach)
+				existing = append(existing, attachment)
 			} else {
-				updating = append(updating, attach)
+				updating = append(updating, attachment)
 			}
 		} else {
-			creating = append(creating, attach)
+			creating = append(creating, attachment)
 		}
 	}
 
-	for i, attach := range creating {
-		log.Infof(nil, "creating attachment: %q", attach.Name)
+	for i, attachment := range creating {
+		log.Infof(nil, "creating attachment: %q", attachment.Name)
 
 		info, err := api.CreateAttachment(
 			page.ID,
-			attach.Filename,
-			AttachmentChecksumPrefix+attach.Checksum,
-			attach.Path,
+			attachment.Filename,
+			AttachmentChecksumPrefix+attachment.Checksum,
+			bytes.NewReader(attachment.FileBytes),
 		)
 		if err != nil {
 			return nil, karma.Format(
 				err,
 				"unable to create attachment %q",
-				attach.Name,
+				attachment.Name,
 			)
 		}
 
-		attach.ID = info.ID
-		attach.Link = path.Join(
+		attachment.ID = info.ID
+		attachment.Link = path.Join(
 			info.Links.Context,
 			info.Links.Download,
 		)
 
-		creating[i] = attach
+		creating[i] = attachment
 	}
 
-	for i, attach := range updating {
-		log.Infof(nil, "updating attachment: %q", attach.Name)
+	for i, attachment := range updating {
+		log.Infof(nil, "updating attachment: %q", attachment.Name)
 
 		info, err := api.UpdateAttachment(
 			page.ID,
-			attach.ID,
-			attach.Filename,
-			AttachmentChecksumPrefix+attach.Checksum,
-			attach.Path,
+			attachment.ID,
+			attachment.Filename,
+			AttachmentChecksumPrefix+attachment.Checksum,
+			bytes.NewReader(attachment.FileBytes),
 		)
 		if err != nil {
 			return nil, karma.Format(
 				err,
 				"unable to update attachment %q",
-				attach.Name,
+				attachment.Name,
 			)
 		}
 
-		attach.Link = path.Join(
+		attachment.Link = path.Join(
 			info.Links.Context,
 			info.Links.Download,
 		)
 
-		updating[i] = attach
+		updating[i] = attachment
 	}
 
 	for i := range existing {
-		log.Infof(nil, "keeping unmodified attachment: %q", attaches[i].Name)
+		log.Infof(nil, "keeping unmodified attachment: %q", attachments[i].Name)
+	}
+
+	attachments = []Attachment{}
+	attachments = append(attachments, existing...)
+	attachments = append(attachments, creating...)
+	attachments = append(attachments, updating...)
+
+	return attachments, nil
+}
+
+func ResolveLocalAttachments(opener vfs.Opener, base string, replacements []string) ([]Attachment, error) {
+	attachments, err := prepareAttachments(opener, base, replacements)
+	if err != nil {
+		return nil, err
 	}
 
-	attaches = []Attachment{}
-	attaches = append(attaches, existing...)
-	attaches = append(attaches, creating...)
-	attaches = append(attaches, updating...)
+	for _, attachment := range attachments {
+		checksum, err := GetChecksum(bytes.NewReader(attachment.FileBytes))
+		if err != nil {
+			return nil, karma.Format(
+				err,
+				"unable to get checksum for attachment: %q", attachment.Name,
+			)
+		}
 
-	return attaches, nil
+		attachment.Checksum = checksum
+	}
+	return attachments, err
 }
 
-func prepareAttachments(base string, replacements []string) ([]Attachment, error) {
-	attaches := []Attachment{}
+// prepareAttachements creates an array of attachement objects based on an array of filepaths
+func prepareAttachments(opener vfs.Opener, base string, replacements []string) ([]Attachment, error) {
+	attachments := []Attachment{}
 	for _, name := range replacements {
-		attach := Attachment{
-			Name:     name,
-			Filename: strings.ReplaceAll(name, "/", "_"),
-			Path:     filepath.Join(base, name),
-			Replace:  name,
+		attachment, err := prepareAttachment(opener, base, name)
+		if err != nil {
+			return nil, err
 		}
 
-		attaches = append(attaches, attach)
+		attachments = append(attachments, attachment)
+	}
+
+	return attachments, nil
+}
+
+// prepareAttachement opens the file, reads its content and creates an attachement object
+func prepareAttachment(opener vfs.Opener, base, name string) (Attachment, error) {
+	attachmentPath := filepath.Join(base, name)
+	file, err := opener.Open(attachmentPath)
+	if err != nil {
+		return Attachment{}, karma.Format(err, "unable to open file: %q", attachmentPath)
+	}
+	defer file.Close()
+
+	fileBytes, err := io.ReadAll(file)
+	if err != nil {
+		return Attachment{}, karma.Format(err, "unable to read file: %q", attachmentPath)
 	}
 
-	return attaches, nil
+	return Attachment{
+		Name:      name,
+		Filename:  strings.ReplaceAll(name, "/", "_"),
+		FileBytes: fileBytes,
+		Replace:   name,
+	}, nil
 }
 
-func CompileAttachmentLinks(markdown []byte, attaches []Attachment) []byte {
+func CompileAttachmentLinks(markdown []byte, attachments []Attachment) []byte {
 	links := map[string]string{}
 	replaces := []string{}
 
-	for _, attach := range attaches {
-		uri, err := url.ParseRequestURI(attach.Link)
-		if err != nil {
-			links[attach.Replace] = strings.ReplaceAll("&", "&amp;", attach.Link)
-		} else {
-			links[attach.Replace] = uri.Path +
-				"?" + url.QueryEscape(uri.Query().Encode())
-		}
-
-		replaces = append(replaces, attach.Replace)
+	for _, attachment := range attachments {
+		links[attachment.Replace] = parseAttachmentLink(attachment.Link)
+		replaces = append(replaces, attachment.Replace)
 	}
 
 	// sort by length so first items will have bigger length
@@ -240,20 +270,21 @@ func CompileAttachmentLinks(markdown []byte, attaches []Attachment) []byte {
 	return markdown
 }
 
-func getChecksum(filename string) (string, error) {
-	file, err := os.Open(filename)
-	if err != nil {
-		return "", karma.Format(
-			err,
-			"unable to open file",
-		)
-	}
-	defer file.Close()
-
+func GetChecksum(reader io.Reader) (string, error) {
 	hash := sha256.New()
-	if _, err := io.Copy(hash, file); err != nil {
+	if _, err := io.Copy(hash, reader); err != nil {
 		return "", err
 	}
 
 	return hex.EncodeToString(hash.Sum(nil)), nil
 }
+
+func parseAttachmentLink(attachLink string) string {
+	uri, err := url.ParseRequestURI(attachLink)
+	if err != nil {
+		return strings.ReplaceAll("&", "&amp;", attachLink)
+	} else {
+		return uri.Path +
+			"?" + url.QueryEscape(uri.Query().Encode())
+	}
+}
diff --git a/pkg/mark/markdown.go b/pkg/mark/markdown.go
index 5b12cd1..9325e5e 100644
--- a/pkg/mark/markdown.go
+++ b/pkg/mark/markdown.go
@@ -48,17 +48,20 @@ func (m BlockQuoteLevelMap) Level(node ast.Node) int {
 // Renderer renders anchor [Node]s.
 type ConfluenceRenderer struct {
 	html.Config
-	Stdlib *stdlib.Lib
-
-	LevelMap BlockQuoteLevelMap
+	Stdlib          *stdlib.Lib
+	MermaidProvider string
+	LevelMap        BlockQuoteLevelMap
+	Attachments     []Attachment
 }
 
 // NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
-func NewConfluenceRenderer(stdlib *stdlib.Lib, opts ...html.Option) renderer.NodeRenderer {
+func NewConfluenceRenderer(stdlib *stdlib.Lib, mermaidProvider string, opts ...html.Option) renderer.NodeRenderer {
 	return &ConfluenceRenderer{
-		Config:   html.NewConfig(),
-		Stdlib:   stdlib,
-		LevelMap: nil,
+		Config:          html.NewConfig(),
+		Stdlib:          stdlib,
+		MermaidProvider: mermaidProvider,
+		LevelMap:        nil,
+		Attachments:     []Attachment{},
 	}
 }
 
@@ -355,29 +358,59 @@ func (r *ConfluenceRenderer) renderFencedCodeBlock(writer util.BufWriter, source
 		line := node.Lines().At(i)
 		lval = append(lval, line.Value(source)...)
 	}
-	err := r.Stdlib.Templates.ExecuteTemplate(
-		writer,
-		"ac:code",
-		struct {
-			Language    string
-			Collapse    bool
-			Title       string
-			Theme       string
-			Linenumbers bool
-			Firstline   int
-			Text        string
-		}{
-			lang,
-			collapse,
-			title,
-			theme,
-			linenumbers,
-			firstline,
-			strings.TrimSuffix(string(lval), "\n"),
-		},
-	)
-	if err != nil {
-		return ast.WalkStop, err
+
+	if lang == "mermaid" && r.MermaidProvider == "mermaid-go" {
+		attachment, err := processMermaidLocally(title, lval)
+		if err != nil {
+			return ast.WalkStop, err
+		}
+		r.Attachments = append(r.Attachments, attachment)
+		err = r.Stdlib.Templates.ExecuteTemplate(
+			writer,
+			"ac:image",
+			struct {
+				Width      string
+				Height     string
+				Title      string
+				Attachment string
+			}{
+				attachment.Width,
+				attachment.Height,
+				attachment.Name,
+				attachment.Filename,
+			},
+		)
+
+		if err != nil {
+			return ast.WalkStop, err
+		}
+
+	} else {
+		err := r.Stdlib.Templates.ExecuteTemplate(
+			writer,
+			"ac:code",
+			struct {
+				Language    string
+				Collapse    bool
+				Title       string
+				Theme       string
+				Linenumbers bool
+				Firstline   int
+				Text        string
+			}{
+				lang,
+				collapse,
+				title,
+				theme,
+				linenumbers,
+				firstline,
+				strings.TrimSuffix(string(lval), "\n"),
+			},
+		)
+
+		if err != nil {
+			return ast.WalkStop, err
+		}
 	}
 
 	return ast.WalkContinue, nil
@@ -430,9 +463,11 @@ func (r *ConfluenceRenderer) renderCodeBlock(writer util.BufWriter, source []byt
 	return ast.WalkContinue, nil
 }
 
-func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib) string {
+func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, mermaidProvider string) (string, []Attachment) {
 	log.Tracef(nil, "rendering markdown:\n%s", string(markdown))
 
+	confluenceRenderer := NewConfluenceRenderer(stdlib, mermaidProvider)
+
 	converter := goldmark.New(
 		goldmark.WithExtensions(
 			extension.GFM,
@@ -455,7 +490,7 @@ func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib) string {
 	))
 
 	converter.Renderer().AddOptions(renderer.WithNodeRenderers(
-		util.Prioritized(NewConfluenceRenderer(stdlib), 100),
+		util.Prioritized(confluenceRenderer, 100),
 	))
 
 	var buf bytes.Buffer
@@ -469,7 +504,8 @@ func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib) string {
 
 	log.Tracef(nil, "rendered markdown to html:\n%s", string(html))
 
-	return string(html)
+	return string(html), confluenceRenderer.(*ConfluenceRenderer).Attachments
+
 }
 
 // DropDocumentLeadingH1 will drop leading H1 headings to prevent
diff --git a/pkg/mark/mermaid.go b/pkg/mark/mermaid.go
new file mode 100644
index 0000000..73df4a5
--- /dev/null
+++ b/pkg/mark/mermaid.go
@@ -0,0 +1,50 @@
+package mark
+
+import (
+	"bytes"
+	"context"
+	"strconv"
+	"time"
+
+	mermaid "github.com/dreampuf/mermaid.go"
+)
+
+var renderTimeout = 60 * time.Second
+
+func processMermaidLocally(title string, mermaidDiagram []byte) (attachement Attachment, err error) {
+	ctx, cancel := context.WithTimeout(context.TODO(), renderTimeout)
+	defer cancel()
+
+	renderer, err := mermaid.NewRenderEngine(ctx)
+
+	if err != nil {
+		return Attachment{}, err
+	}
+
+	pngBytes, boxModel, err := renderer.RenderAsPng(string(mermaidDiagram))
+	if err != nil {
+		return Attachment{}, err
+	}
+
+	checkSum, err := GetChecksum(bytes.NewReader(mermaidDiagram))
+
+	if err != nil {
+		return Attachment{}, err
+	}
+	if title == "" {
+		title = checkSum
+	}
+
+	fileName := title + ".png"
+
+	return Attachment{
+		ID:        "",
+		Name:      title,
+		Filename:  fileName,
+		FileBytes: pngBytes,
+		Checksum:  checkSum,
+		Replace:   title,
+		Width:     strconv.FormatInt(boxModel.Width, 10),
+		Height:    strconv.FormatInt(boxModel.Height, 10),
+	}, nil
+}
diff --git a/pkg/mark/parser/confluencetags.go b/pkg/mark/parser/confluencetags.go
index 56bba8d..54e7ff0 100644
--- a/pkg/mark/parser/confluencetags.go
+++ b/pkg/mark/parser/confluencetags.go
@@ -2,11 +2,12 @@ package parser
 
 import (
 	"bytes"
+	"regexp"
+
 	"github.com/yuin/goldmark/ast"
 	"github.com/yuin/goldmark/parser"
 	"github.com/yuin/goldmark/text"
 	"github.com/yuin/goldmark/util"
-	"regexp"
 )
 
 // NewConfluenceTagParser returns an inline parser that parses <ac:* /> and <ri:* /> tags to ensure that Confluence specific tags are parsed
diff --git a/pkg/mark/stdlib/stdlib.go b/pkg/mark/stdlib/stdlib.go
index 6df86d8..bf294b8 100644
--- a/pkg/mark/stdlib/stdlib.go
+++ b/pkg/mark/stdlib/stdlib.go
@@ -42,13 +42,13 @@ func macros(templates *template.Template) ([]macro.Macro, error) {
 
 	macros, _, err := macro.ExtractMacros(
 		"",
-		[]byte(text(
+		text(
 			`<!-- Macro: @\{([^}]+)\}`,
 			`     Template: ac:link:user`,
 			`     Name: ${1} -->`,
 
 			// TODO(seletskiy): more macros here
-		)),
+		),
 
 		templates,
 	)
@@ -218,7 +218,9 @@ func templates(api *confluence.API) (*template.Template, error) {
 			`<ac:emoticon ac:name="{{ .Name }}"/>`,
 		),
 		`ac:image`: text(
-			`<ac:image {{ if .Width}}ac:width="{{ .Width }}"{{end}}><ri:attachment ri:filename="{{ .Attachment | convertAttachment }}"/></ac:image>`,
+			`<ac:image{{ if .Width}} ac:width="{{ .Width }}"{{end}}{{ if .Height }} ac:height="{{ .Height }}"{{end}}{{ if .Title }} ac:title="{{ .Title }}"{{end}}>{{printf "\n"}}`,
+			`<ri:attachment ri:filename="{{ .Attachment | convertAttachment }}"/>{{printf "\n"}}`,
+			`</ac:image>{{printf "\n"}}`,
 		),
 
 		/* https://confluence.atlassian.com/doc/widget-connector-macro-171180449.html#WidgetConnectorMacro-YouTube */
diff --git a/pkg/mark/vfs/vfs.go b/pkg/mark/vfs/vfs.go
new file mode 100644
index 0000000..b23d627
--- /dev/null
+++ b/pkg/mark/vfs/vfs.go
@@ -0,0 +1,19 @@
+package vfs
+
+import (
+	"io"
+	"os"
+)
+
+type Opener interface {
+	Open(name string) (io.ReadWriteCloser, error)
+}
+
+type LocalOSOpener struct {
+}
+
+func (o LocalOSOpener) Open(name string) (io.ReadWriteCloser, error) {
+	return os.Open(name)
+}
+
+var LocalOS = LocalOSOpener{}


Test patch:
diff --git a/pkg/mark/attachment_test.go b/pkg/mark/attachment_test.go
index 8fe39fd..714a6a4 100644
--- a/pkg/mark/attachment_test.go
+++ b/pkg/mark/attachment_test.go
@@ -1,6 +1,9 @@
 package mark
 
 import (
+	"bytes"
+	"io"
+	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -14,43 +17,74 @@ var (
 	}
 )
 
+type bufferCloser struct {
+	*bytes.Buffer
+}
+
+func (bufferCloser) Close() error { return nil }
+
+type virtualOpener struct {
+	PathToBuf map[string]*bufferCloser
+}
+
+func (o *virtualOpener) Open(name string) (io.ReadWriteCloser, error) {
+	if buf, ok := o.PathToBuf[name]; ok {
+		return buf, nil
+	}
+	return nil, os.ErrNotExist
+}
+
 func TestPrepareAttachmentsWithWorkDirBase(t *testing.T) {
 
-	attaches, err := prepareAttachments(".", replacements)
+	testingOpener := &virtualOpener{
+		PathToBuf: map[string]*bufferCloser{
+			"image1.jpg":        {bytes.NewBuffer(nil)},
+			"images/image2.jpg": {bytes.NewBuffer(nil)},
+			"../image3.jpg":     {bytes.NewBuffer(nil)},
+		},
+	}
+
+	attaches, err := prepareAttachments(testingOpener, ".", replacements)
+	t.Logf("attatches: %s", err)
 	if err != nil {
 		println(err.Error())
+		t.Fatal(err)
 	}
 
 	assert.Equal(t, "image1.jpg", attaches[0].Name)
 	assert.Equal(t, "image1.jpg", attaches[0].Replace)
-	assert.Equal(t, "image1.jpg", attaches[0].Path)
 
 	assert.Equal(t, "images/image2.jpg", attaches[1].Name)
 	assert.Equal(t, "images/image2.jpg", attaches[1].Replace)
-	assert.Equal(t, "images/image2.jpg", attaches[1].Path)
 
 	assert.Equal(t, "../image3.jpg", attaches[2].Name)
 	assert.Equal(t, "../image3.jpg", attaches[2].Replace)
-	assert.Equal(t, "../image3.jpg", attaches[2].Path)
 
 	assert.Equal(t, len(attaches), 3)
 }
 
 func TestPrepareAttachmentsWithSubDirBase(t *testing.T) {
 
-	attaches, _ := prepareAttachments("a/b", replacements)
+	testingOpener := &virtualOpener{
+		PathToBuf: map[string]*bufferCloser{
+			"a/b/image1.jpg":        {bytes.NewBuffer(nil)},
+			"a/b/images/image2.jpg": {bytes.NewBuffer(nil)},
+			"a/image3.jpg":          {bytes.NewBuffer(nil)},
+		},
+	}
 
+	attaches, err := prepareAttachments(testingOpener, "a/b", replacements)
+	if err != nil {
+		t.Fatal(err)
+	}
 	assert.Equal(t, "image1.jpg", attaches[0].Name)
 	assert.Equal(t, "image1.jpg", attaches[0].Replace)
-	assert.Equal(t, "a/b/image1.jpg", attaches[0].Path)
 
 	assert.Equal(t, "images/image2.jpg", attaches[1].Name)
 	assert.Equal(t, "images/image2.jpg", attaches[1].Replace)
-	assert.Equal(t, "a/b/images/image2.jpg", attaches[1].Path)
 
 	assert.Equal(t, "../image3.jpg", attaches[2].Name)
 	assert.Equal(t, "../image3.jpg", attaches[2].Replace)
-	assert.Equal(t, "a/image3.jpg", attaches[2].Path)
 
 	assert.Equal(t, len(attaches), 3)
 }
diff --git a/pkg/mark/markdown_test.go b/pkg/mark/markdown_test.go
index d48ebbc..a4c5053 100644
--- a/pkg/mark/markdown_test.go
+++ b/pkg/mark/markdown_test.go
@@ -36,7 +36,7 @@ func TestCompileMarkdown(t *testing.T) {
 		if err != nil {
 			panic(err)
 		}
-		actual := CompileMarkdown(markdown, lib)
+		actual, _ := CompileMarkdown(markdown, lib, "")
 		test.EqualValues(string(html), actual, filename+" vs "+htmlname)
 	}
 }
diff --git a/pkg/mark/mermaid_test.go b/pkg/mark/mermaid_test.go
new file mode 100644
index 0000000..357db91
--- /dev/null
+++ b/pkg/mark/mermaid_test.go
@@ -0,0 +1,38 @@
+package mark
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestExtractMermaidImage(t *testing.T) {
+	tests := []struct {
+		name     string
+		markdown []byte
+		want     Attachment
+		wantErr  assert.ErrorAssertionFunc
+	}{
+		{"example", []byte("graph TD;\n A-->B;"), Attachment{
+			FileBytes: []byte{0x89, 0x50, 0x4e, 0x47, 0xd, 0xa, 0x1a, 0xa, 0x0, 0x0, 0x0, 0xd, 0x49, 0x48, 0x44, 0x52, 0x0, 0x0, 0x0, 0x2f, 0x0, 0x0, 0x0, 0x8e, 0x8, 0x6, 0x0, 0x0, 0x0, 0xa1, 0x67, 0x51, 0xe7, 0x0, 0x0, 0x0, 0x1, 0x73, 0x52, 0x47, 0x42, 0x0, 0xae, 0xce, 0x1c, 0xe9, 0x0, 0x0, 0x4, 0xc0, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0xed, 0x9c, 0x5f, 0x48, 0x5b, 0x57, 0x1c, 0xc7, 0xbf, 0xb7, 0xb6, 0x7a, 0xdb, 0xe8, 0x22, 0xce, 0xce, 0x18, 0xe6, 0xfc, 0xb7, 0x3f, 0x4a, 0xab, 0x2d, 0x31, 0xb5, 0x5, 0xd7, 0x3a, 0x25, 0x56, 0x5b, 0xc3, 0x64, 0x4f, 0x52, 0x28, 0x94, 0x3d, 0x58, 0xd7, 0x87, 0x15, 0x56, 0xda, 0x87, 0x8e, 0x62, 0xe9, 0xcb, 0x28, 0x4c, 0x46, 0xa1, 0x43, 0x29, 0x3e, 0xf8, 0xb0, 0x87, 0x31, 0x18, 0x48, 0xab, 0x73, 0x5d, 0xa2, 0xd5, 0xe0, 0x90, 0x4e, 0x92, 0x58, 0x86, 0xed, 0x64, 0xa2, 0xab, 0x22, 0xa9, 0xb3, 0xba, 0x6e, 0x6a, 0x52, 0x67, 0xaf, 0x66, 0x4f, 0x13, 0x52, 0x93, 0x5c, 0xcd, 0xce, 0xf1, 0xb7, 0xc0, 0xef, 0xf3, 0x94, 0x9b, 0x73, 0x72, 0xbe, 0x1f, 0x7e, 0x9c, 0x24, 0x37, 0xb9, 0xe7, 0x1e, 0x25, 0x18, 0xc, 0x6, 0x11, 0xa7, 0xec, 0xa0, 0x16, 0xf8, 0x2f, 0xb0, 0x3c, 0x15, 0x2c, 0x4f, 0x45, 0x5c, 0xcb, 0xef, 0x8c, 0xd6, 0x38, 0xf3, 0x78, 0x19, 0x53, 0xa3, 0x81, 0xed, 0x72, 0xd9, 0xc0, 0x1b, 0x5, 0x7b, 0x60, 0xca, 0x51, 0x23, 0xb6, 0x47, 0x95, 0x7f, 0xf2, 0xdb, 0x32, 0xc6, 0x47, 0x2, 0xc8, 0xcc, 0xdb, 0x23, 0x5c, 0x4c, 0x8f, 0x27, 0x13, 0x1, 0x24, 0xaa, 0x3b, 0x62, 0x97, 0x7, 0x80, 0xf4, 0xd7, 0x55, 0x14, 0x95, 0xa7, 0x9, 0x15, 0xdb, 0xc, 0x2f, 0x56, 0xd6, 0x74, 0xfb, 0xc4, 0xf5, 0x9c, 0x67, 0x79, 0x2a, 0x58, 0x9e, 0xa, 0x96, 0xa7, 0x82, 0xe5, 0xa9, 0x60, 0xf9, 0x70, 0x74, 0x77, 0x77, 0xa0, 0xba, 0xda, 0x8a, 0xa6, 0xa6, 0x4f, 0x64, 0x45, 0xc8, 0x93, 0x77, 0x3a, 0x3b, 0x91, 0x9b, 0xfb, 0x26, 0xdc, 0xee, 0x41, 0x3c, 0x7b, 0xf6, 0x87, 0x94, 0xc, 0x29, 0xf2, 0xd3, 0xd3, 0x53, 0x78, 0xf4, 0xe8, 0x67, 0x9c, 0x3b, 0x77, 0x11, 0xaa, 0xba, 0x1b, 0x7d, 0x7d, 0x77, 0x65, 0xc4, 0xc8, 0x91, 0x77, 0x38, 0xee, 0xc0, 0x64, 0x32, 0xa3, 0xb8, 0xb8, 0x4, 0x15, 0x15, 0x35, 0x70, 0x3a, 0x3b, 0x65, 0xc4, 0x88, 0x97, 0x5f, 0x5b, 0x5b, 0x43, 0x6f, 0x6f, 0x37, 0x6c, 0x36, 0x3b, 0x14, 0x45, 0x41, 0x55, 0x95, 0x1d, 0x13, 0x13, 0x63, 0x18, 0x1f, 0xff, 0x55, 0x74, 0x94, 0x78, 0xf9, 0xe1, 0xe1, 0x21, 0xcc, 0xcd, 0xcd, 0xa2, 0xaa, 0xca, 0xe, 0x0, 0x28, 0x28, 0xd8, 0x8f, 0xac, 0xac, 0x1c, 0xf4, 0xf6, 0x7e, 0x27, 0x3a, 0x4a, 0xbc, 0xbc, 0xc3, 0x71, 0x7, 0xc5, 0xc5, 0x16, 0xa4, 0xa7, 0xbf, 0x6, 0x4d, 0xd3, 0xa0, 0x69, 0x1a, 0x6c, 0xb6, 0x5a, 0xdc, 0xbb, 0xf7, 0x3d, 0x34, 0x4d, 0x13, 0x9a, 0xa5, 0xfb, 0x4b, 0x6a, 0x2b, 0xf8, 0xfd, 0x4b, 0x18, 0x1c, 0xec, 0xc7, 0xca, 0xca, 0xa, 0x6a, 0x6b, 0x8f, 0x6c, 0x68, 0xf7, 0x78, 0xee, 0xe3, 0xf0, 0xe1, 0x77, 0x85, 0xe5, 0x9, 0x95, 0xef, 0xef, 0xff, 0x1, 0x8a, 0xa2, 0xe0, 0xc6, 0x8d, 0x76, 0x24, 0x24, 0x24, 0x84, 0xb4, 0xb5, 0xb6, 0x36, 0xa3, 0xa7, 0xa7, 0xeb, 0xff, 0x2b, 0xef, 0x70, 0x74, 0xe2, 0xd0, 0xa1, 0x32, 0x14, 0x16, 0x16, 0x6d, 0x68, 0xab, 0xac, 0x3c, 0x81, 0x5b, 0xb7, 0xbe, 0xc0, 0xe2, 0xe2, 0x2, 0x52, 0x52, 0x5e, 0x11, 0x92, 0x27, 0x6c, 0xce, 0xfb, 0x7c, 0xd3, 0x18, 0x1d, 0x1d, 0xc1, 0xd1, 0xa3, 0xb6, 0xb0, 0xed, 0x65, 0x65, 0x95, 0xd0, 0x34, 0xd, 0x2e, 0x97, 0x43, 0x54, 0xa4, 0x38, 0xf9, 0x81, 0x81, 0x1e, 0x24, 0x25, 0x25, 0xa1, 0xb4, 0x34, 0xfc, 0xb4, 0x48, 0x4b, 0x7b, 0x15, 0x45, 0x45, 0x16, 0x38, 0x9d, 0x5d, 0xa2, 0x22, 0xa1, 0x44, 0xfb, 0x7f, 0x7e, 0xb8, 0xef, 0x4f, 0xcc, 0xfd, 0xae, 0xc1, 0x52, 0x95, 0x2e, 0x2c, 0x70, 0xb3, 0x78, 0x9d, 0x73, 0xd8, 0x6b, 0xda, 0x89, 0x83, 0xef, 0xa5, 0x46, 0xec, 0xc3, 0x67, 0x95, 0x54, 0xb0, 0x3c, 0x15, 0x2c, 0x4f, 0x5, 0xcb, 0x53, 0xc1, 0xf2, 0x54, 0xb0, 0x3c, 0x15, 0xba, 0xe7, 0xf3, 0x73, 0xd3, 0xcb, 0x18, 0x19, 0x90, 0xf3, 0xbf, 0x8b, 0x5e, 0xee, 0x5e, 0x53, 0x72, 0xd4, 0x3e, 0x51, 0xe5, 0x33, 0x73, 0x55, 0xbc, 0xf8, 0x5b, 0xff, 0xc2, 0x56, 0x38, 0xdc, 0x6e, 0x37, 0xbc, 0x5e, 0x2f, 0x2c, 0x16, 0xb, 0xac, 0x56, 0xeb, 0x96, 0x5f, 0x9f, 0xbf, 0x3f, 0xfa, 0x65, 0x4c, 0x40, 0x47, 0xde, 0x94, 0xa3, 0xea, 0xe, 0x10, 0x9, 0xef, 0xe3, 0x9, 0x3c, 0x98, 0xfa, 0x16, 0xd6, 0xe3, 0xa9, 0x28, 0xad, 0x39, 0x1e, 0xd3, 0x18, 0x7a, 0xc4, 0xf5, 0x9c, 0x67, 0x79, 0x2a, 0x58, 0x9e, 0xa, 0x96, 0xa7, 0x82, 0xe5, 0xa9, 0x60, 0x79, 0x2a, 0x58, 0x9e, 0xa, 0x96, 0xa7, 0x82, 0xe5, 0xa9, 0x60, 0x79, 0x2a, 0x58, 0x9e, 0xa, 0x96, 0xa7, 0x82, 0xe5, 0xa9, 0x60, 0x79, 0x2a, 0x58, 0x9e, 0xa, 0x96, 0xa7, 0x82, 0xe5, 0xa9, 0x60, 0x79, 0x2a, 0x58, 0x9e, 0x8a, 0xa8, 0xb, 0xe4, 0x36, 0x8b, 0xdb, 0xed, 0x46, 0x5b, 0x5b, 0x5b, 0xc8, 0x73, 0x3e, 0x9f, 0xf, 0x3e, 0x9f, 0xf, 0x66, 0xb3, 0x19, 0x66, 0xb3, 0x39, 0xa4, 0xad, 0xa1, 0xa1, 0x21, 0xa6, 0xcb, 0x9b, 0x2f, 0x23, 0x44, 0x1e, 0x0, 0xec, 0x76, 0x3b, 0x66, 0x66, 0x66, 0x74, 0xfb, 0x99, 0x4c, 0x26, 0x74, 0x75, 0x89, 0x59, 0x9e, 0x28, 0x6c, 0xda, 0x34, 0x36, 0x36, 0xa, 0xed, 0xb7, 0x19, 0x84, 0x55, 0x1e, 0xd0, 0xaf, 0xbe, 0xc8, 0xaa, 0x3, 0x82, 0xdf, 0xb0, 0x7a, 0x55, 0x15, 0x59, 0x75, 0x40, 0x70, 0xe5, 0x81, 0xc8, 0xd5, 0x17, 0x5d, 0x75, 0x40, 0xc2, 0x47, 0x65, 0xa4, 0xea, 0x8a, 0xae, 0x3a, 0x20, 0xa1, 0xf2, 0xc0, 0xc6, 0xea, 0xcb, 0xa8, 0x3a, 0x20, 0xe9, 0x4b, 0xea, 0xe5, 0x2a, 0xcb, 0xa8, 0x3a, 0x20, 0xa9, 0xf2, 0x0, 0x50, 0x5e, 0x5e, 0xe, 0xbf, 0xdf, 0xf, 0x83, 0xc1, 0x0, 0x97, 0xcb, 0x25, 0x23, 0x42, 0xde, 0xbe, 0x7, 0x36, 0xcb, 0x47, 0x98, 0x9c, 0x9c, 0x44, 0x76, 0x76, 0x36, 0x86, 0xee, 0xc6, 0xb6, 0x52, 0x4a, 0x6f, 0xdf, 0x3, 0xdd, 0xf5, 0xf3, 0xa3, 0x9e, 0x25, 0xb2, 0x7d, 0xf, 0xa, 0xad, 0xc9, 0x51, 0xd7, 0xcf, 0xf3, 0xbe, 0x7, 0x54, 0xb0, 0x3c, 0x15, 0x2c, 0x4f, 0x5, 0xcb, 0x53, 0xc1, 0xf2, 0x54, 0xc4, 0xb5, 0xbc, 0xd0, 0x5b, 0x4a, 0x6f, 0xdf, 0xfe, 0x6, 0x2d, 0x2d, 0x9f, 0xaf, 0x1f, 0x2b, 0x8a, 0x2, 0xa3, 0x31, 0x15, 0xf9, 0xf9, 0xef, 0xa0, 0xbe, 0xfe, 0x43, 0x1c, 0x38, 0x50, 0x22, 0x32, 0x4e, 0xac, 0xfc, 0xbf, 0x5c, 0xbd, 0xda, 0xc, 0x83, 0x21, 0x19, 0xc1, 0x60, 0x10, 0xf3, 0xf3, 0x4f, 0xd1, 0xd1, 0xf1, 0x35, 0xae, 0x5c, 0xf9, 0x18, 0x37, 0x6f, 0x7e, 0x85, 0x9c, 0x9c, 0x7c, 0x61, 0x39, 0x52, 0xe4, 0xf7, 0xed, 0x3b, 0x8, 0xa3, 0x31, 0x35, 0xe4, 0xf8, 0xcc, 0x99, 0xf7, 0x31, 0x34, 0xf4, 0xa3, 0x50, 0xf9, 0x6d, 0x99, 0xf3, 0xbb, 0x76, 0x25, 0x2, 0x0, 0x52, 0x52, 0x8c, 0x42, 0xc7, 0x95, 0x52, 0xf9, 0xd5, 0xd5, 0x55, 0x68, 0x9a, 0x86, 0x60, 0x30, 0x88, 0xd9, 0xd9, 0x19, 0xb4, 0xb7, 0x7f, 0x89, 0x8c, 0x8c, 0x4c, 0x1c, 0x3b, 0x16, 0xfe, 0x2e, 0xe5, 0x58, 0x91, 0x22, 0x7f, 0xea, 0x54, 0x75, 0xc8, 0x71, 0x46, 0x46, 0x26, 0x2e, 0x5f, 0xfe, 0xc, 0x6, 0x43, 0xf4, 0x7b, 0x40, 0xb6, 0x8a, 0x14, 0xf9, 0xeb, 0xd7, 0x5b, 0xd6, 0x45, 0x17, 0x17, 0x17, 0xe0, 0xf1, 0xdc, 0xc7, 0xa5, 0x4b, 0x67, 0x71, 0xe1, 0x42, 0x13, 0x2a, 0x2b, 0x4f, 0x8, 0xcb, 0x91, 0x22, 0x9f, 0x97, 0xf7, 0x76, 0xc8, 0x1b, 0xb6, 0xa4, 0xe4, 0x8, 0x2, 0x81, 0x25, 0xb4, 0xb6, 0x36, 0xa3, 0xa2, 0xa2, 0x6, 0x8a, 0xa2, 0x8, 0xc9, 0xd9, 0xb6, 0x2f, 0xa9, 0xdc, 0xdc, 0xb7, 0xb0, 0xb0, 0xf0, 0x17, 0xe6, 0xe7, 0x9f, 0xa, 0x1b, 0x73, 0xdb, 0xe4, 0xc7, 0xc6, 0x7e, 0x41, 0x62, 0x62, 0x22, 0x92, 0x93, 0xc5, 0xec, 0x79, 0x0, 0x48, 0x9a, 0x36, 0xf, 0x1f, 0x3e, 0x58, 0x9f, 0xf3, 0xcf, 0x9f, 0x7, 0xe0, 0xf5, 0xfe, 0x4, 0xa7, 0xb3, 0xb, 0xa7, 0x4f, 0x9f, 0x85, 0xaa, 0xc6, 0x76, 0xeb, 0x52, 0x38, 0xa4, 0xc8, 0x5f, 0xbb, 0x76, 0x71, 0xfd, 0xb1, 0xaa, 0xee, 0x46, 0x56, 0x56, 0x36, 0xce, 0x9f, 0xff, 0x14, 0x27, 0x4f, 0x7e, 0x20, 0x34, 0x47, 0xa8, 0x7c, 0x5d, 0x5d, 0x3d, 0xea, 0xea, 0xea, 0x45, 0xe, 0x19, 0x95, 0xb8, 0x3e, 0xab, 0x64, 0x79, 0x2a, 0x58, 0x9e, 0xa, 0x96, 0xa7, 0x82, 0xe5, 0xa9, 0x60, 0x79, 0x2a, 0xe2, 0x5a, 0x9e, 0xf7, 0x3d, 0x90, 0xc1, 0x66, 0xf6, 0x3d, 0x90, 0x76, 0x11, 0x79, 0x3b, 0x88, 0xeb, 0x39, 0xcf, 0xf2, 0x54, 0xb0, 0x3c, 0x15, 0x71, 0x2d, 0xff, 0xf, 0x4a, 0x95, 0x78, 0x8f, 0xd5, 0x30, 0xc5, 0xba, 0x0, 0x0, 0x0, 0x0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82},
+			Filename:  "example.png",
+			Name:      "example",
+			Replace:   "example",
+			Checksum:  "1743a4f31ab66244591f06c8056e08053b8e0a554eb9a38709af6e9d145ac84f",
+			ID:        "",
+			Width:     "47",
+			Height:    "142",
+		},
+			assert.NoError},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := processMermaidLocally(tt.name, tt.markdown)
+			if !tt.wantErr(t, err, fmt.Sprintf("processMermaidLocally(%v, %v)", tt.name, tt.markdown)) {
+				return
+			}
+			assert.Equalf(t, tt.want, got, "processMermaidLocally(%v, %v)", tt.name, tt.markdown)
+		})
+	}
+}