Repository: kovetskiy/mark
Pull request #349 (2023-09-01 21:09:08)

chore: Migrate to a goldmark extension
Move code a bit around to make it easier to read and create a goldmark extension.


Patch:
diff --git a/main.go b/main.go
index d0ff70f..e65fce4 100644
--- a/main.go
+++ b/main.go
@@ -11,6 +11,7 @@ import (
 	"github.com/kovetskiy/lorg"
 	"github.com/kovetskiy/mark/pkg/confluence"
 	"github.com/kovetskiy/mark/pkg/mark"
+	"github.com/kovetskiy/mark/pkg/mark/attachment"
 	"github.com/kovetskiy/mark/pkg/mark/includes"
 	"github.com/kovetskiy/mark/pkg/mark/macro"
 	"github.com/kovetskiy/mark/pkg/mark/stdlib"
@@ -436,12 +437,12 @@ func processFile(
 	}
 
 	// Resolve attachments created from <!-- Attachment: --> directive
-	localAttachments, err := mark.ResolveLocalAttachments(vfs.LocalOS, filepath.Dir(file), meta.Attachments)
+	localAttachments, err := attachment.ResolveLocalAttachments(vfs.LocalOS, filepath.Dir(file), meta.Attachments)
 	if err != nil {
 		log.Fatalf(err, "unable to locate attachments")
 	}
 
-	attaches, err := mark.ResolveAttachments(
+	attaches, err := attachment.ResolveAttachments(
 		api,
 		target,
 		localAttachments,
@@ -450,7 +451,7 @@ func processFile(
 		log.Fatalf(err, "unable to create/update attachments")
 	}
 
-	markdown = mark.CompileAttachmentLinks(markdown, attaches)
+	markdown = attachment.CompileAttachmentLinks(markdown, attaches)
 
 	if cCtx.Bool("drop-h1") {
 		log.Info(
@@ -461,7 +462,7 @@ func processFile(
 	html, inlineAttachments := mark.CompileMarkdown(markdown, stdlib, file, cCtx.String("mermaid-provider"), cCtx.Float64("mermaid-scale"), cCtx.Bool("drop-h1"))
 
 	// Resolve attachements detected from markdown
-	_, err = mark.ResolveAttachments(
+	_, err = attachment.ResolveAttachments(
 		api,
 		target,
 		inlineAttachments,
diff --git a/pkg/mark/attachment.go b/pkg/mark/attachment/attachment.go
similarity index 99%
rename from pkg/mark/attachment.go
rename to pkg/mark/attachment/attachment.go
index f2ec385..d2a9a3e 100644
--- a/pkg/mark/attachment.go
+++ b/pkg/mark/attachment/attachment.go
@@ -1,4 +1,4 @@
-package mark
+package attachment
 
 import (
 	"bytes"
diff --git a/pkg/mark/markdown.go b/pkg/mark/markdown.go
index 085eb43..f970257 100644
--- a/pkg/mark/markdown.go
+++ b/pkg/mark/markdown.go
@@ -2,18 +2,15 @@ package mark
 
 import (
 	"bytes"
-	"fmt"
-	"path/filepath"
 	"regexp"
-	"strings"
 
+	"github.com/kovetskiy/mark/pkg/mark/attachment"
 	cparser "github.com/kovetskiy/mark/pkg/mark/parser"
+	crenderer "github.com/kovetskiy/mark/pkg/mark/renderer"
 	"github.com/kovetskiy/mark/pkg/mark/stdlib"
-	"github.com/kovetskiy/mark/pkg/mark/vfs"
 	"github.com/reconquest/pkg/log"
 	"github.com/yuin/goldmark"
 
-	"github.com/yuin/goldmark/ast"
 	"github.com/yuin/goldmark/extension"
 	"github.com/yuin/goldmark/parser"
 	"github.com/yuin/goldmark/renderer"
@@ -21,642 +18,60 @@ import (
 	"github.com/yuin/goldmark/util"
 )
 
-var reBlockDetails = regexp.MustCompile(
-	// (<Lang>|-) (collapse|<theme>|\d)* (title <title>)?
-
-	`^(?:(\w*)|-)\s*\b(\S.*?\S?)??\s*(?:\btitle\s+(\S.*\S?))?$`,
-)
-
-// Define BlockQuoteType enum
-type BlockQuoteType int
-
-const (
-	Info BlockQuoteType = iota
-	Note
-	Warn
-	None
-)
-
-func (t BlockQuoteType) String() string {
-	return []string{"info", "note", "warn", "none"}[t]
-}
-
-type BlockQuoteLevelMap map[ast.Node]int
-
-func (m BlockQuoteLevelMap) Level(node ast.Node) int {
-	return m[node]
-}
-
 // Renderer renders anchor [Node]s.
-type ConfluenceRenderer struct {
+type ConfluenceExtension struct {
 	html.Config
 	Stdlib          *stdlib.Lib
 	Path            string
 	MermaidProvider string
 	MermaidScale    float64
 	DropFirstH1     bool
-	LevelMap        BlockQuoteLevelMap
-	Attachments     []Attachment
+	Attachments     []attachment.Attachment
 }
 
 // NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
-func NewConfluenceRenderer(stdlib *stdlib.Lib, path string, mermaidProvider string, mermaidScale float64, dropFirstH1 bool, opts ...html.Option) renderer.NodeRenderer {
-	return &ConfluenceRenderer{
+func NewConfluenceExtension(stdlib *stdlib.Lib, path string, mermaidProvider string, mermaidScale float64, dropFirstH1 bool) *ConfluenceExtension {
+	return &ConfluenceExtension{
 		Config:          html.NewConfig(),
 		Stdlib:          stdlib,
 		Path:            path,
 		MermaidProvider: mermaidProvider,
 		MermaidScale:    mermaidScale,
 		DropFirstH1:     dropFirstH1,
-		LevelMap:        nil,
-		Attachments:     []Attachment{},
-	}
-}
-
-// RegisterFuncs implements NodeRenderer.RegisterFuncs .
-func (r *ConfluenceRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
-	// blocks
-	// reg.Register(ast.KindDocument, r.renderNode)
-	reg.Register(ast.KindHeading, r.renderHeading)
-	reg.Register(ast.KindBlockquote, r.renderBlockQuote)
-	reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
-	reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
-	reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
-	// reg.Register(ast.KindList, r.renderNode)
-	// reg.Register(ast.KindListItem, r.renderNode)
-	// reg.Register(ast.KindParagraph, r.renderNode)
-	// reg.Register(ast.KindTextBlock, r.renderNode)
-	// reg.Register(ast.KindThematicBreak, r.renderNode)
-
-	// inlines
-	// reg.Register(ast.KindAutoLink, r.renderNode)
-	// reg.Register(ast.KindCodeSpan, r.renderNode)
-	// reg.Register(ast.KindEmphasis, r.renderNode)
-	reg.Register(ast.KindImage, r.renderImage)
-	reg.Register(ast.KindLink, r.renderLink)
-	// reg.Register(ast.KindRawHTML, r.renderNode)
-	// reg.Register(ast.KindText, r.renderNode)
-	// reg.Register(ast.KindString, r.renderNode)
-}
-
-func (r *ConfluenceRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
-	n := node.(*ast.Heading)
-
-	// If this is the first h1 heading of the document and we want to drop it, let's not render it at all.
-	if n.Level == 1 && r.DropFirstH1 {
-		if !entering {
-			r.DropFirstH1 = false
-		}
-		return ast.WalkSkipChildren, nil
-	}
-
-	return r.goldmarkRenderHeading(w, source, node, entering)
-}
-
-func (r *ConfluenceRenderer) goldmarkRenderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
-	n := node.(*ast.Heading)
-	if entering {
-		_, _ = w.WriteString("<h")
-		_ = w.WriteByte("0123456"[n.Level])
-		if n.Attributes() != nil {
-			html.RenderAttributes(w, node, html.HeadingAttributeFilter)
-		}
-		_ = w.WriteByte('>')
-	} else {
-		_, _ = w.WriteString("</h")
-		_ = w.WriteByte("0123456"[n.Level])
-		_, _ = w.WriteString(">\n")
-	}
-	return ast.WalkContinue, nil
-}
-
-func ParseLanguage(lang string) string {
-	// lang takes the following form: language? "collapse"? ("title"? <any string>*)?
-	// let's split it by spaces
-	paramlist := strings.Fields(lang)
-
-	// get the word in question, aka the first one
-	first := lang
-	if len(paramlist) > 0 {
-		first = paramlist[0]
-	}
-
-	if first == "collapse" || first == "title" {
-		// collapsing or including a title without a language
-		return ""
-	}
-	// the default case with language being the first one
-	return first
-}
-
-func ParseTitle(lang string) string {
-	index := strings.Index(lang, "title")
-	if index >= 0 {
-		// it's found, check if title is given and return it
-		start := index + 6
-		if len(lang) > start {
-			return lang[start:]
-		}
-	}
-	return ""
-}
-
-// ClassifyingBlockQuote compares a string against a set of patterns and returns a BlockQuoteType
-func ClassifyingBlockQuote(literal string) BlockQuoteType {
-	infoPattern := regexp.MustCompile(`info|Info|INFO`)
-	notePattern := regexp.MustCompile(`note|Note|NOTE`)
-	warnPattern := regexp.MustCompile(`warn|Warn|WARN`)
-
-	var t = None
-	switch {
-	case infoPattern.MatchString(literal):
-		t = Info
-	case notePattern.MatchString(literal):
-		t = Note
-	case warnPattern.MatchString(literal):
-		t = Warn
-	}
-	return t
-}
-
-// ParseBlockQuoteType parses the first line of a blockquote and returns its type
-func ParseBlockQuoteType(node ast.Node, source []byte) BlockQuoteType {
-	var t = None
-
-	countParagraphs := 0
-	_ = ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
-
-		if node.Kind() == ast.KindParagraph && entering {
-			countParagraphs += 1
-		}
-		// Type of block quote should be defined on the first blockquote line
-		if countParagraphs < 2 && entering {
-			if node.Kind() == ast.KindText {
-				n := node.(*ast.Text)
-				t = ClassifyingBlockQuote(string(n.Text(source)))
-				countParagraphs += 1
-			}
-			if node.Kind() == ast.KindHTMLBlock {
-
-				n := node.(*ast.HTMLBlock)
-				for i := 0; i < n.BaseBlock.Lines().Len(); i++ {
-					line := n.BaseBlock.Lines().At(i)
-					t = ClassifyingBlockQuote(string(line.Value(source)))
-					if t != None {
-						break
-					}
-				}
-				countParagraphs += 1
-			}
-		} else if countParagraphs > 1 && entering {
-			return ast.WalkStop, nil
-		}
-		return ast.WalkContinue, nil
-	})
-
-	return t
-}
-
-// GenerateBlockQuoteLevel walks a given node and returns a map of blockquote levels
-func GenerateBlockQuoteLevel(someNode ast.Node) BlockQuoteLevelMap {
-
-	// We define state variable that track BlockQuote level while we walk the tree
-	blockQuoteLevel := 0
-	blockQuoteLevelMap := make(map[ast.Node]int)
-
-	rootNode := someNode
-	for rootNode.Parent() != nil {
-		rootNode = rootNode.Parent()
-	}
-	_ = ast.Walk(rootNode, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
-		if node.Kind() == ast.KindBlockquote && entering {
-			blockQuoteLevelMap[node] = blockQuoteLevel
-			blockQuoteLevel += 1
-		}
-		if node.Kind() == ast.KindBlockquote && !entering {
-			blockQuoteLevel -= 1
-		}
-		return ast.WalkContinue, nil
-	})
-	return blockQuoteLevelMap
-}
-
-// renderBlockQuote will render a BlockQuote
-func (r *ConfluenceRenderer) renderBlockQuote(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
-	// Initialize BlockQuote level map
-	if r.LevelMap == nil {
-		r.LevelMap = GenerateBlockQuoteLevel(node)
-	}
-
-	quoteType := ParseBlockQuoteType(node, source)
-	quoteLevel := r.LevelMap.Level(node)
-
-	if quoteLevel == 0 && entering && quoteType != None {
-		prefix := fmt.Sprintf("<ac:structured-macro ac:name=\"%s\"><ac:parameter ac:name=\"icon\">true</ac:parameter><ac:rich-text-body>\n", quoteType)
-		if _, err := writer.Write([]byte(prefix)); err != nil {
-			return ast.WalkStop, err
-		}
-		return ast.WalkContinue, nil
-	}
-	if quoteLevel == 0 && !entering && quoteType != None {
-		suffix := "</ac:rich-text-body></ac:structured-macro>\n"
-		if _, err := writer.Write([]byte(suffix)); err != nil {
-			return ast.WalkStop, err
-		}
-		return ast.WalkContinue, nil
-	}
-	return r.goldmarkRenderBlockquote(writer, source, node, entering)
-}
-
-// goldmarkRenderBlockquote is the default renderBlockquote implementation from https://github.com/yuin/goldmark/blob/9d6f314b99ca23037c93d76f248be7b37de6220a/renderer/html/html.go#L286
-func (r *ConfluenceRenderer) goldmarkRenderBlockquote(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
-	if entering {
-		if n.Attributes() != nil {
-			_, _ = w.WriteString("<blockquote")
-			html.RenderAttributes(w, n, html.BlockquoteAttributeFilter)
-			_ = w.WriteByte('>')
-		} else {
-			_, _ = w.WriteString("<blockquote>\n")
-		}
-	} else {
-		_, _ = w.WriteString("</blockquote>\n")
-	}
-	return ast.WalkContinue, nil
-}
-
-// renderLink renders links specifically for confluence
-func (r *ConfluenceRenderer) renderLink(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
-	if string(node.(*ast.Link).Destination[0:3]) == "ac:" {
-		if entering {
-			_, err := writer.Write([]byte("<ac:link><ri:page ri:content-title=\""))
-			if err != nil {
-				return ast.WalkStop, err
-			}
-
-			if len(node.(*ast.Link).Destination) < 4 {
-				_, err := writer.Write(node.FirstChild().Text(source))
-				if err != nil {
-					return ast.WalkStop, err
-				}
-			} else {
-				_, err := writer.Write(node.(*ast.Link).Destination[3:])
-				if err != nil {
-					return ast.WalkStop, err
-				}
-
-			}
-			_, err = writer.Write([]byte("\"/><ac:plain-text-link-body><![CDATA["))
-			if err != nil {
-				return ast.WalkStop, err
-			}
-
-			_, err = writer.Write(node.FirstChild().Text(source))
-			if err != nil {
-				return ast.WalkStop, err
-			}
-
-			_, err = writer.Write([]byte("]]></ac:plain-text-link-body></ac:link>"))
-			if err != nil {
-				return ast.WalkStop, err
-			}
-		}
-		return ast.WalkSkipChildren, nil
-	}
-	return r.goldmarkRenderLink(writer, source, node, entering)
-}
-
-// goldmarkRenderLink is the default renderLink implementation from https://github.com/yuin/goldmark/blob/9d6f314b99ca23037c93d76f248be7b37de6220a/renderer/html/html.go#L552
-func (r *ConfluenceRenderer) goldmarkRenderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
-	n := node.(*ast.Link)
-	if entering {
-		_, _ = w.WriteString("<a href=\"")
-		if r.Unsafe || !html.IsDangerousURL(n.Destination) {
-			_, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
-		}
-		_ = w.WriteByte('"')
-		if n.Title != nil {
-			_, _ = w.WriteString(` title="`)
-			r.Writer.Write(w, n.Title)
-			_ = w.WriteByte('"')
-		}
-		if n.Attributes() != nil {
-			html.RenderAttributes(w, n, html.LinkAttributeFilter)
-		}
-		_ = w.WriteByte('>')
-	} else {
-		_, _ = w.WriteString("</a>")
-	}
-	return ast.WalkContinue, nil
-}
-
-// renderFencedCodeBlock renders a FencedCodeBlock
-func (r *ConfluenceRenderer) renderFencedCodeBlock(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
-	if !entering {
-		return ast.WalkContinue, nil
-	}
-	var info []byte
-	nodeFencedCodeBlock := node.(*ast.FencedCodeBlock)
-	if nodeFencedCodeBlock.Info != nil {
-		segment := nodeFencedCodeBlock.Info.Segment
-		info = segment.Value(source)
-	}
-	groups := reBlockDetails.FindStringSubmatch(string(info))
-	linenumbers := false
-	firstline := 0
-	theme := ""
-	collapse := false
-	lang := ""
-	var options []string
-	title := ""
-	if len(groups) > 0 {
-		lang, options, title = groups[1], strings.Fields(groups[2]), groups[3]
-		for _, option := range options {
-			if option == "collapse" {
-				collapse = true
-				continue
-			}
-			if option == "nocollapse" {
-				collapse = false
-				continue
-			}
-			var i int
-			if _, err := fmt.Sscanf(option, "%d", &i); err == nil {
-				linenumbers = i > 0
-				firstline = i
-				continue
-			}
-			theme = option
-		}
-
-	}
-
-	var lval []byte
-
-	lines := node.Lines().Len()
-	for i := 0; i < lines; i++ {
-		line := node.Lines().At(i)
-		lval = append(lval, line.Value(source)...)
-	}
-
-	if lang == "mermaid" && r.MermaidProvider == "mermaid-go" {
-		attachment, err := processMermaidLocally(title, lval, r.MermaidScale)
-		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
-				Alt        string
-				Attachment string
-				Url        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
-		}
+		Attachments:     []attachment.Attachment{},
 	}
-
-	return ast.WalkContinue, nil
 }
 
-// renderCodeBlock renders a CodeBlock
-func (r *ConfluenceRenderer) renderCodeBlock(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
-	if !entering {
-		return ast.WalkContinue, nil
-	}
-	linenumbers := false
-	firstline := 0
-	theme := ""
-	collapse := false
-	lang := ""
-	title := ""
-
-	var lval []byte
-
-	lines := node.Lines().Len()
-	for i := 0; i < lines; i++ {
-		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
-	}
-
-	return ast.WalkContinue, nil
-}
-
-// renderImage renders an inline image
-func (r *ConfluenceRenderer) renderImage(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
-	if !entering {
-		return ast.WalkContinue, nil
-	}
-	n := node.(*ast.Image)
-
-	attachments, err := ResolveLocalAttachments(vfs.LocalOS, filepath.Dir(r.Path), []string{string(n.Destination)})
-
-	// We were unable to resolve it locally, treat as URL
-	if err != nil {
-		escapedURL := string(n.Destination)
-		escapedURL = strings.ReplaceAll(escapedURL, "&", "&amp;")
-
-		err = r.Stdlib.Templates.ExecuteTemplate(
-			writer,
-			"ac:image",
-			struct {
-				Width      string
-				Height     string
-				Title      string
-				Alt        string
-				Attachment string
-				Url        string
-			}{
-				"",
-				"",
-				string(n.Title),
-				string(nodeToHTMLText(n, source)),
-				"",
-				escapedURL,
-			},
-		)
-	} else {
+func (c *ConfluenceExtension) Extend(m goldmark.Markdown) {
 
-		r.Attachments = append(r.Attachments, attachments[0])
-
-		err = r.Stdlib.Templates.ExecuteTemplate(
-			writer,
-			"ac:image",
-			struct {
-				Width      string
-				Height     string
-				Title      string
-				Alt        string
-				Attachment string
-				Url        string
-			}{
-				"",
-				"",
-				string(n.Title),
-				string(nodeToHTMLText(n, source)),
-				attachments[0].Filename,
-				"",
-			},
-		)
-	}
-
-	if err != nil {
-		return ast.WalkStop, err
-	}
-
-	return ast.WalkSkipChildren, nil
-}
-
-func (r *ConfluenceRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
-	if !entering {
-		return r.goldmarkRenderHTMLBlock(w, source, node, entering)
-	}
-
-	n := node.(*ast.HTMLBlock)
-	l := n.Lines().Len()
-	for i := 0; i < l; i++ {
-		line := n.Lines().At(i)
-
-		switch strings.Trim(string(line.Value(source)), "\n") {
-		case "<!-- ac:layout -->":
-			_, _ = w.WriteString("<ac:layout>\n")
-			return ast.WalkContinue, nil
-		case "<!-- ac:layout end -->":
-			_, _ = w.WriteString("</ac:layout>\n")
-			return ast.WalkContinue, nil
-		case "<!-- ac:layout-section type:single -->":
-			_, _ = w.WriteString("<ac:layout-section type=\"single\">\n")
-			return ast.WalkContinue, nil
-		case "<!-- ac:layout-section type:two_equal -->":
-			_, _ = w.WriteString("<ac:layout-section type=\"two_equal\">\n")
-			return ast.WalkContinue, nil
-		case "<!-- ac:layout-section type:two_left_sidebar -->":
-			_, _ = w.WriteString("<ac:layout-section type=\"two_left_sidebar\">\n")
-			return ast.WalkContinue, nil
-		case "<!-- ac:layout-section type:two_right_sidebar -->":
-			_, _ = w.WriteString("<ac:layout-section type=\"two_right_sidebar\">\n")
-			return ast.WalkContinue, nil
-		case "<!-- ac:layout-section type:three -->":
-			_, _ = w.WriteString("<ac:layout-section type=\"three\">\n")
-			return ast.WalkContinue, nil
-		case "<!-- ac:layout-section type:three_with_sidebars -->":
-			_, _ = w.WriteString("<ac:layout-section type=\"three_with_sidebars\">\n")
-			return ast.WalkContinue, nil
-		case "<!-- ac:layout-section end -->":
-			_, _ = w.WriteString("</ac:layout-section>\n")
-			return ast.WalkContinue, nil
-		case "<!-- ac:layout-cell -->":
-			_, _ = w.WriteString("<ac:layout-cell>\n")
-			return ast.WalkContinue, nil
-		case "<!-- ac:layout-cell end -->":
-			_, _ = w.WriteString("</ac:layout-cell>\n")
-			return ast.WalkContinue, nil
-
-		}
-	}
-	return r.goldmarkRenderHTMLBlock(w, source, node, entering)
-
-}
+	m.Renderer().AddOptions(renderer.WithNodeRenderers(
+		util.Prioritized(crenderer.NewConfluenceBlockQuoteRenderer(), 100),
+		util.Prioritized(crenderer.NewConfluenceCodeBlockRenderer(c.Stdlib, c.Path), 100),
+		util.Prioritized(crenderer.NewConfluenceFencedCodeBlockRenderer(c.Stdlib, &c.Attachments, c.MermaidProvider, c.MermaidScale), 100),
+		util.Prioritized(crenderer.NewConfluenceHTMLBlockRenderer(c.Stdlib), 100),
+		util.Prioritized(crenderer.NewConfluenceHeadingRenderer(c.DropFirstH1), 100),
+		util.Prioritized(crenderer.NewConfluenceImageRenderer(c.Stdlib, &c.Attachments, c.Path), 100),
+		util.Prioritized(crenderer.NewConfluenceLinkRenderer(), 100),
+	))
 
-func (r *ConfluenceRenderer) goldmarkRenderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
-	n := node.(*ast.HTMLBlock)
-	if entering {
-		if r.Unsafe {
-			l := n.Lines().Len()
-			for i := 0; i < l; i++ {
-				line := n.Lines().At(i)
-				r.Writer.SecureWrite(w, line.Value(source))
-			}
-		} else {
-			_, _ = w.WriteString("<!-- raw HTML omitted -->\n")
-		}
-	} else {
-		if n.HasClosure() {
-			if r.Unsafe {
-				closure := n.ClosureLine
-				r.Writer.SecureWrite(w, closure.Value(source))
-			} else {
-				_, _ = w.WriteString("<!-- raw HTML omitted -->\n")
-			}
-		}
-	}
-	return ast.WalkContinue, nil
+	m.Parser().AddOptions(parser.WithInlineParsers(
+		// Must be registered with a higher priority than goldmark's linkParser to make sure goldmark doesn't parse
+		// the <ac:*/> tags.
+		util.Prioritized(cparser.NewConfluenceTagParser(), 199),
+	))
 }
 
-func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, mermaidProvider string, mermaidScale float64, dropFirstH1 bool) (string, []Attachment) {
+func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, mermaidProvider string, mermaidScale float64, dropFirstH1 bool) (string, []attachment.Attachment) {
 	log.Tracef(nil, "rendering markdown:\n%s", string(markdown))
 
-	confluenceRenderer := NewConfluenceRenderer(stdlib, path, mermaidProvider, mermaidScale, dropFirstH1)
+	confluenceExtension := NewConfluenceExtension(stdlib, path, mermaidProvider, mermaidScale, dropFirstH1)
 
 	converter := goldmark.New(
 		goldmark.WithExtensions(
 			extension.GFM,
 			extension.Footnote,
 			extension.DefinitionList,
+			confluenceExtension,
 		),
 		goldmark.WithParserOptions(
 			parser.WithAutoHeadingID(),
@@ -666,16 +81,6 @@ func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, mermaidPr
 			html.WithUnsafe(),
 		))
 
-	converter.Parser().AddOptions(parser.WithInlineParsers(
-		// Must be registered with a higher priority than goldmark's linkParser to make sure goldmark doesn't parse
-		// the <ac:*/> tags.
-		util.Prioritized(cparser.NewConfluenceTagParser(), 199),
-	))
-
-	converter.Renderer().AddOptions(renderer.WithNodeRenderers(
-		util.Prioritized(confluenceRenderer, 100),
-	))
-
 	var buf bytes.Buffer
 	err := converter.Convert(markdown, &buf)
 
@@ -687,7 +92,7 @@ func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, mermaidPr
 
 	log.Tracef(nil, "rendered markdown to html:\n%s", string(html))
 
-	return string(html), confluenceRenderer.(*ConfluenceRenderer).Attachments
+	return string(html), confluenceExtension.Attachments
 
 }
 
@@ -701,18 +106,3 @@ func ExtractDocumentLeadingH1(markdown []byte) string {
 		return string(groups[1])
 	}
 }
-
-// https://github.com/yuin/goldmark/blob/c446c414ef3a41fb562da0ae5badd18f1502c42f/renderer/html/html.go
-func nodeToHTMLText(n ast.Node, source []byte) []byte {
-	var buf bytes.Buffer
-	for c := n.FirstChild(); c != nil; c = c.NextSibling() {
-		if s, ok := c.(*ast.String); ok && s.IsCode() {
-			buf.Write(s.Text(source))
-		} else if !c.HasChildren() {
-			buf.Write(util.EscapeHTML(c.Text(source)))
-		} else {
-			buf.Write(nodeToHTMLText(c, source))
-		}
-	}
-	return buf.Bytes()
-}
diff --git a/pkg/mark/mermaid.go b/pkg/mark/mermaid/mermaid.go
similarity index 63%
rename from pkg/mark/mermaid.go
rename to pkg/mark/mermaid/mermaid.go
index 81b75cb..dc25792 100644
--- a/pkg/mark/mermaid.go
+++ b/pkg/mark/mermaid/mermaid.go
@@ -1,4 +1,4 @@
-package mark
+package mermaid
 
 import (
 	"bytes"
@@ -7,29 +7,30 @@ import (
 	"time"
 
 	mermaid "github.com/dreampuf/mermaid.go"
+	"github.com/kovetskiy/mark/pkg/mark/attachment"
 )
 
 var renderTimeout = 60 * time.Second
 
-func processMermaidLocally(title string, mermaidDiagram []byte, scale float64) (attachement Attachment, err error) {
+func ProcessMermaidLocally(title string, mermaidDiagram []byte, scale float64) (attachement attachment.Attachment, err error) {
 	ctx, cancel := context.WithTimeout(context.TODO(), renderTimeout)
 	defer cancel()
 
 	renderer, err := mermaid.NewRenderEngine(ctx)
 
 	if err != nil {
-		return Attachment{}, err
+		return attachment.Attachment{}, err
 	}
 
 	pngBytes, boxModel, err := renderer.RenderAsScaledPng(string(mermaidDiagram), scale)
 	if err != nil {
-		return Attachment{}, err
+		return attachment.Attachment{}, err
 	}
 
-	checkSum, err := GetChecksum(bytes.NewReader(mermaidDiagram))
+	checkSum, err := attachment.GetChecksum(bytes.NewReader(mermaidDiagram))
 
 	if err != nil {
-		return Attachment{}, err
+		return attachment.Attachment{}, err
 	}
 	if title == "" {
 		title = checkSum
@@ -37,7 +38,7 @@ func processMermaidLocally(title string, mermaidDiagram []byte, scale float64) (
 
 	fileName := title + ".png"
 
-	return Attachment{
+	return attachment.Attachment{
 		ID:        "",
 		Name:      title,
 		Filename:  fileName,
diff --git a/pkg/mark/renderer/blockquote.go b/pkg/mark/renderer/blockquote.go
new file mode 100644
index 0000000..9d21573
--- /dev/null
+++ b/pkg/mark/renderer/blockquote.go
@@ -0,0 +1,172 @@
+package renderer
+
+import (
+	"fmt"
+	"regexp"
+
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/renderer"
+	"github.com/yuin/goldmark/renderer/html"
+	"github.com/yuin/goldmark/util"
+)
+
+type ConfluenceBlockQuoteRenderer struct {
+	html.Config
+	LevelMap BlockQuoteLevelMap
+}
+
+// NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
+func NewConfluenceBlockQuoteRenderer(opts ...html.Option) renderer.NodeRenderer {
+	return &ConfluenceBlockQuoteRenderer{
+		Config:   html.NewConfig(),
+		LevelMap: nil,
+	}
+}
+
+// RegisterFuncs implements NodeRenderer.RegisterFuncs .
+func (r *ConfluenceBlockQuoteRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(ast.KindBlockquote, r.renderBlockQuote)
+}
+
+// Define BlockQuoteType enum
+type BlockQuoteType int
+
+const (
+	Info BlockQuoteType = iota
+	Note
+	Warn
+	None
+)
+
+func (t BlockQuoteType) String() string {
+	return []string{"info", "note", "warn", "none"}[t]
+}
+
+type BlockQuoteLevelMap map[ast.Node]int
+
+func (m BlockQuoteLevelMap) Level(node ast.Node) int {
+	return m[node]
+}
+
+// ClassifyingBlockQuote compares a string against a set of patterns and returns a BlockQuoteType
+func ClassifyingBlockQuote(literal string) BlockQuoteType {
+	infoPattern := regexp.MustCompile(`info|Info|INFO`)
+	notePattern := regexp.MustCompile(`note|Note|NOTE`)
+	warnPattern := regexp.MustCompile(`warn|Warn|WARN`)
+
+	var t = None
+	switch {
+	case infoPattern.MatchString(literal):
+		t = Info
+	case notePattern.MatchString(literal):
+		t = Note
+	case warnPattern.MatchString(literal):
+		t = Warn
+	}
+	return t
+}
+
+// ParseBlockQuoteType parses the first line of a blockquote and returns its type
+func ParseBlockQuoteType(node ast.Node, source []byte) BlockQuoteType {
+	var t = None
+
+	countParagraphs := 0
+	_ = ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
+
+		if node.Kind() == ast.KindParagraph && entering {
+			countParagraphs += 1
+		}
+		// Type of block quote should be defined on the first blockquote line
+		if countParagraphs < 2 && entering {
+			if node.Kind() == ast.KindText {
+				n := node.(*ast.Text)
+				t = ClassifyingBlockQuote(string(n.Text(source)))
+				countParagraphs += 1
+			}
+			if node.Kind() == ast.KindHTMLBlock {
+
+				n := node.(*ast.HTMLBlock)
+				for i := 0; i < n.BaseBlock.Lines().Len(); i++ {
+					line := n.BaseBlock.Lines().At(i)
+					t = ClassifyingBlockQuote(string(line.Value(source)))
+					if t != None {
+						break
+					}
+				}
+				countParagraphs += 1
+			}
+		} else if countParagraphs > 1 && entering {
+			return ast.WalkStop, nil
+		}
+		return ast.WalkContinue, nil
+	})
+
+	return t
+}
+
+// GenerateBlockQuoteLevel walks a given node and returns a map of blockquote levels
+func GenerateBlockQuoteLevel(someNode ast.Node) BlockQuoteLevelMap {
+
+	// We define state variable that track BlockQuote level while we walk the tree
+	blockQuoteLevel := 0
+	blockQuoteLevelMap := make(map[ast.Node]int)
+
+	rootNode := someNode
+	for rootNode.Parent() != nil {
+		rootNode = rootNode.Parent()
+	}
+	_ = ast.Walk(rootNode, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
+		if node.Kind() == ast.KindBlockquote && entering {
+			blockQuoteLevelMap[node] = blockQuoteLevel
+			blockQuoteLevel += 1
+		}
+		if node.Kind() == ast.KindBlockquote && !entering {
+			blockQuoteLevel -= 1
+		}
+		return ast.WalkContinue, nil
+	})
+	return blockQuoteLevelMap
+}
+
+// renderBlockQuote will render a BlockQuote
+func (r *ConfluenceBlockQuoteRenderer) renderBlockQuote(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	// Initialize BlockQuote level map
+	if r.LevelMap == nil {
+		r.LevelMap = GenerateBlockQuoteLevel(node)
+	}
+
+	quoteType := ParseBlockQuoteType(node, source)
+	quoteLevel := r.LevelMap.Level(node)
+
+	if quoteLevel == 0 && entering && quoteType != None {
+		prefix := fmt.Sprintf("<ac:structured-macro ac:name=\"%s\"><ac:parameter ac:name=\"icon\">true</ac:parameter><ac:rich-text-body>\n", quoteType)
+		if _, err := writer.Write([]byte(prefix)); err != nil {
+			return ast.WalkStop, err
+		}
+		return ast.WalkContinue, nil
+	}
+	if quoteLevel == 0 && !entering && quoteType != None {
+		suffix := "</ac:rich-text-body></ac:structured-macro>\n"
+		if _, err := writer.Write([]byte(suffix)); err != nil {
+			return ast.WalkStop, err
+		}
+		return ast.WalkContinue, nil
+	}
+	return r.goldmarkRenderBlockquote(writer, source, node, entering)
+}
+
+// goldmarkRenderBlockquote is the default renderBlockquote implementation from https://github.com/yuin/goldmark/blob/9d6f314b99ca23037c93d76f248be7b37de6220a/renderer/html/html.go#L286
+func (r *ConfluenceBlockQuoteRenderer) goldmarkRenderBlockquote(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+	if entering {
+		if n.Attributes() != nil {
+			_, _ = w.WriteString("<blockquote")
+			html.RenderAttributes(w, n, html.BlockquoteAttributeFilter)
+			_ = w.WriteByte('>')
+		} else {
+			_, _ = w.WriteString("<blockquote>\n")
+		}
+	} else {
+		_, _ = w.WriteString("</blockquote>\n")
+	}
+	return ast.WalkContinue, nil
+}
diff --git a/pkg/mark/renderer/codeblock.go b/pkg/mark/renderer/codeblock.go
new file mode 100644
index 0000000..99b02d6
--- /dev/null
+++ b/pkg/mark/renderer/codeblock.go
@@ -0,0 +1,77 @@
+package renderer
+
+import (
+	"strings"
+
+	"github.com/kovetskiy/mark/pkg/mark/stdlib"
+
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/renderer"
+	"github.com/yuin/goldmark/renderer/html"
+	"github.com/yuin/goldmark/util"
+)
+
+type ConfluenceCodeBlockRenderer struct {
+	html.Config
+	Stdlib *stdlib.Lib
+}
+
+// NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
+func NewConfluenceCodeBlockRenderer(stdlib *stdlib.Lib, path string, opts ...html.Option) renderer.NodeRenderer {
+	return &ConfluenceCodeBlockRenderer{
+		Config: html.NewConfig(),
+		Stdlib: stdlib,
+	}
+}
+
+// RegisterFuncs implements NodeRenderer.RegisterFuncs .
+func (r *ConfluenceCodeBlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
+}
+
+// renderCodeBlock renders a CodeBlock
+func (r *ConfluenceCodeBlockRenderer) renderCodeBlock(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	if !entering {
+		return ast.WalkContinue, nil
+	}
+	linenumbers := false
+	firstline := 0
+	theme := ""
+	collapse := false
+	lang := ""
+	title := ""
+
+	var lval []byte
+
+	lines := node.Lines().Len()
+	for i := 0; i < lines; i++ {
+		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
+	}
+
+	return ast.WalkContinue, nil
+}
diff --git a/pkg/mark/renderer/fencedcodeblock.go b/pkg/mark/renderer/fencedcodeblock.go
new file mode 100644
index 0000000..c3d071d
--- /dev/null
+++ b/pkg/mark/renderer/fencedcodeblock.go
@@ -0,0 +1,187 @@
+package renderer
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+
+	"github.com/kovetskiy/mark/pkg/mark/attachment"
+	"github.com/kovetskiy/mark/pkg/mark/mermaid"
+	"github.com/kovetskiy/mark/pkg/mark/stdlib"
+
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/renderer"
+	"github.com/yuin/goldmark/renderer/html"
+	"github.com/yuin/goldmark/util"
+)
+
+type ConfluenceFencedCodeBlockRenderer struct {
+	html.Config
+	Stdlib          *stdlib.Lib
+	MermaidProvider string
+	MermaidScale    float64
+	Attachments     []attachment.Attachment
+}
+
+var reBlockDetails = regexp.MustCompile(
+	// (<Lang>|-) (collapse|<theme>|\d)* (title <title>)?
+
+	`^(?:(\w*)|-)\s*\b(\S.*?\S?)??\s*(?:\btitle\s+(\S.*\S?))?$`,
+)
+
+// NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
+func NewConfluenceFencedCodeBlockRenderer(stdlib *stdlib.Lib, attachments *[]attachment.Attachment, mermaidProvider string, mermaidScale float64, opts ...html.Option) renderer.NodeRenderer {
+	return &ConfluenceFencedCodeBlockRenderer{
+		Config:          html.NewConfig(),
+		Stdlib:          stdlib,
+		MermaidProvider: mermaidProvider,
+		MermaidScale:    mermaidScale,
+		Attachments:     *attachments,
+	}
+}
+
+// RegisterFuncs implements NodeRenderer.RegisterFuncs .
+func (r *ConfluenceFencedCodeBlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
+}
+
+func ParseLanguage(lang string) string {
+	// lang takes the following form: language? "collapse"? ("title"? <any string>*)?
+	// let's split it by spaces
+	paramlist := strings.Fields(lang)
+
+	// get the word in question, aka the first one
+	first := lang
+	if len(paramlist) > 0 {
+		first = paramlist[0]
+	}
+
+	if first == "collapse" || first == "title" {
+		// collapsing or including a title without a language
+		return ""
+	}
+	// the default case with language being the first one
+	return first
+}
+
+func ParseTitle(lang string) string {
+	index := strings.Index(lang, "title")
+	if index >= 0 {
+		// it's found, check if title is given and return it
+		start := index + 6
+		if len(lang) > start {
+			return lang[start:]
+		}
+	}
+	return ""
+}
+
+// renderFencedCodeBlock renders a FencedCodeBlock
+func (r *ConfluenceFencedCodeBlockRenderer) renderFencedCodeBlock(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	if !entering {
+		return ast.WalkContinue, nil
+	}
+	var info []byte
+	nodeFencedCodeBlock := node.(*ast.FencedCodeBlock)
+	if nodeFencedCodeBlock.Info != nil {
+		segment := nodeFencedCodeBlock.Info.Segment
+		info = segment.Value(source)
+	}
+	groups := reBlockDetails.FindStringSubmatch(string(info))
+	linenumbers := false
+	firstline := 0
+	theme := ""
+	collapse := false
+	lang := ""
+	var options []string
+	title := ""
+	if len(groups) > 0 {
+		lang, options, title = groups[1], strings.Fields(groups[2]), groups[3]
+		for _, option := range options {
+			if option == "collapse" {
+				collapse = true
+				continue
+			}
+			if option == "nocollapse" {
+				collapse = false
+				continue
+			}
+			var i int
+			if _, err := fmt.Sscanf(option, "%d", &i); err == nil {
+				linenumbers = i > 0
+				firstline = i
+				continue
+			}
+			theme = option
+		}
+
+	}
+
+	var lval []byte
+
+	lines := node.Lines().Len()
+	for i := 0; i < lines; i++ {
+		line := node.Lines().At(i)
+		lval = append(lval, line.Value(source)...)
+	}
+
+	if lang == "mermaid" && r.MermaidProvider == "mermaid-go" {
+		attachment, err := mermaid.ProcessMermaidLocally(title, lval, r.MermaidScale)
+		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
+				Alt        string
+				Attachment string
+				Url        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
+}
diff --git a/pkg/mark/renderer/heading.go b/pkg/mark/renderer/heading.go
new file mode 100644
index 0000000..8d33cef
--- /dev/null
+++ b/pkg/mark/renderer/heading.go
@@ -0,0 +1,57 @@
+package renderer
+
+import (
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/renderer"
+	"github.com/yuin/goldmark/renderer/html"
+	"github.com/yuin/goldmark/util"
+)
+
+type ConfluenceHeadingRenderer struct {
+	html.Config
+	DropFirstH1 bool
+}
+
+// NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
+func NewConfluenceHeadingRenderer(dropFirstH1 bool, opts ...html.Option) renderer.NodeRenderer {
+	return &ConfluenceHeadingRenderer{
+		Config:      html.NewConfig(),
+		DropFirstH1: dropFirstH1,
+	}
+}
+
+// RegisterFuncs implements NodeRenderer.RegisterFuncs .
+func (r *ConfluenceHeadingRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(ast.KindHeading, r.renderHeading)
+}
+
+func (r *ConfluenceHeadingRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	n := node.(*ast.Heading)
+
+	// If this is the first h1 heading of the document and we want to drop it, let's not render it at all.
+	if n.Level == 1 && r.DropFirstH1 {
+		if !entering {
+			r.DropFirstH1 = false
+		}
+		return ast.WalkSkipChildren, nil
+	}
+
+	return r.goldmarkRenderHeading(w, source, node, entering)
+}
+
+func (r *ConfluenceHeadingRenderer) goldmarkRenderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	n := node.(*ast.Heading)
+	if entering {
+		_, _ = w.WriteString("<h")
+		_ = w.WriteByte("0123456"[n.Level])
+		if n.Attributes() != nil {
+			html.RenderAttributes(w, node, html.HeadingAttributeFilter)
+		}
+		_ = w.WriteByte('>')
+	} else {
+		_, _ = w.WriteString("</h")
+		_ = w.WriteByte("0123456"[n.Level])
+		_, _ = w.WriteString(">\n")
+	}
+	return ast.WalkContinue, nil
+}
diff --git a/pkg/mark/renderer/htmlblock.go b/pkg/mark/renderer/htmlblock.go
new file mode 100644
index 0000000..7f900b5
--- /dev/null
+++ b/pkg/mark/renderer/htmlblock.go
@@ -0,0 +1,104 @@
+package renderer
+
+import (
+	"strings"
+
+	"github.com/kovetskiy/mark/pkg/mark/stdlib"
+
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/renderer"
+	"github.com/yuin/goldmark/renderer/html"
+	"github.com/yuin/goldmark/util"
+)
+
+type ConfluenceHTMLBlockRenderer struct {
+	html.Config
+}
+
+// NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
+func NewConfluenceHTMLBlockRenderer(stdlib *stdlib.Lib, opts ...html.Option) renderer.NodeRenderer {
+	return &ConfluenceHTMLBlockRenderer{
+		Config: html.NewConfig(),
+	}
+}
+
+// RegisterFuncs implements NodeRenderer.RegisterFuncs .
+func (r *ConfluenceHTMLBlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
+}
+
+func (r *ConfluenceHTMLBlockRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	if !entering {
+		return r.goldmarkRenderHTMLBlock(w, source, node, entering)
+	}
+
+	n := node.(*ast.HTMLBlock)
+	l := n.Lines().Len()
+	for i := 0; i < l; i++ {
+		line := n.Lines().At(i)
+
+		switch strings.Trim(string(line.Value(source)), "\n") {
+		case "<!-- ac:layout -->":
+			_, _ = w.WriteString("<ac:layout>\n")
+			return ast.WalkContinue, nil
+		case "<!-- ac:layout end -->":
+			_, _ = w.WriteString("</ac:layout>\n")
+			return ast.WalkContinue, nil
+		case "<!-- ac:layout-section type:single -->":
+			_, _ = w.WriteString("<ac:layout-section type=\"single\">\n")
+			return ast.WalkContinue, nil
+		case "<!-- ac:layout-section type:two_equal -->":
+			_, _ = w.WriteString("<ac:layout-section type=\"two_equal\">\n")
+			return ast.WalkContinue, nil
+		case "<!-- ac:layout-section type:two_left_sidebar -->":
+			_, _ = w.WriteString("<ac:layout-section type=\"two_left_sidebar\">\n")
+			return ast.WalkContinue, nil
+		case "<!-- ac:layout-section type:two_right_sidebar -->":
+			_, _ = w.WriteString("<ac:layout-section type=\"two_right_sidebar\">\n")
+			return ast.WalkContinue, nil
+		case "<!-- ac:layout-section type:three -->":
+			_, _ = w.WriteString("<ac:layout-section type=\"three\">\n")
+			return ast.WalkContinue, nil
+		case "<!-- ac:layout-section type:three_with_sidebars -->":
+			_, _ = w.WriteString("<ac:layout-section type=\"three_with_sidebars\">\n")
+			return ast.WalkContinue, nil
+		case "<!-- ac:layout-section end -->":
+			_, _ = w.WriteString("</ac:layout-section>\n")
+			return ast.WalkContinue, nil
+		case "<!-- ac:layout-cell -->":
+			_, _ = w.WriteString("<ac:layout-cell>\n")
+			return ast.WalkContinue, nil
+		case "<!-- ac:layout-cell end -->":
+			_, _ = w.WriteString("</ac:layout-cell>\n")
+			return ast.WalkContinue, nil
+
+		}
+	}
+	return r.goldmarkRenderHTMLBlock(w, source, node, entering)
+
+}
+
+func (r *ConfluenceHTMLBlockRenderer) goldmarkRenderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	n := node.(*ast.HTMLBlock)
+	if entering {
+		if r.Unsafe {
+			l := n.Lines().Len()
+			for i := 0; i < l; i++ {
+				line := n.Lines().At(i)
+				r.Writer.SecureWrite(w, line.Value(source))
+			}
+		} else {
+			_, _ = w.WriteString("<!-- raw HTML omitted -->\n")
+		}
+	} else {
+		if n.HasClosure() {
+			if r.Unsafe {
+				closure := n.ClosureLine
+				r.Writer.SecureWrite(w, closure.Value(source))
+			} else {
+				_, _ = w.WriteString("<!-- raw HTML omitted -->\n")
+			}
+		}
+	}
+	return ast.WalkContinue, nil
+}
diff --git a/pkg/mark/renderer/image.go b/pkg/mark/renderer/image.go
new file mode 100644
index 0000000..06af435
--- /dev/null
+++ b/pkg/mark/renderer/image.go
@@ -0,0 +1,118 @@
+package renderer
+
+import (
+	"bytes"
+	"path/filepath"
+	"strings"
+
+	"github.com/kovetskiy/mark/pkg/mark/attachment"
+	"github.com/kovetskiy/mark/pkg/mark/stdlib"
+	"github.com/kovetskiy/mark/pkg/mark/vfs"
+
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/renderer"
+	"github.com/yuin/goldmark/renderer/html"
+	"github.com/yuin/goldmark/util"
+)
+
+type ConfluenceImageRenderer struct {
+	html.Config
+	Stdlib      *stdlib.Lib
+	Path        string
+	Attachments []attachment.Attachment
+}
+
+// NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
+func NewConfluenceImageRenderer(stdlib *stdlib.Lib, attachments *[]attachment.Attachment, path string, opts ...html.Option) renderer.NodeRenderer {
+	return &ConfluenceImageRenderer{
+		Config:      html.NewConfig(),
+		Stdlib:      stdlib,
+		Path:        path,
+		Attachments: *attachments,
+	}
+}
+
+// RegisterFuncs implements NodeRenderer.RegisterFuncs .
+func (r *ConfluenceImageRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(ast.KindImage, r.renderImage)
+}
+
+// renderImage renders an inline image
+func (r *ConfluenceImageRenderer) renderImage(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	if !entering {
+		return ast.WalkContinue, nil
+	}
+	n := node.(*ast.Image)
+
+	attachments, err := attachment.ResolveLocalAttachments(vfs.LocalOS, filepath.Dir(r.Path), []string{string(n.Destination)})
+
+	// We were unable to resolve it locally, treat as URL
+	if err != nil {
+		escapedURL := string(n.Destination)
+		escapedURL = strings.ReplaceAll(escapedURL, "&", "&amp;")
+
+		err = r.Stdlib.Templates.ExecuteTemplate(
+			writer,
+			"ac:image",
+			struct {
+				Width      string
+				Height     string
+				Title      string
+				Alt        string
+				Attachment string
+				Url        string
+			}{
+				"",
+				"",
+				string(n.Title),
+				string(nodeToHTMLText(n, source)),
+				"",
+				escapedURL,
+			},
+		)
+	} else {
+
+		r.Attachments = append(r.Attachments, attachments[0])
+
+		err = r.Stdlib.Templates.ExecuteTemplate(
+			writer,
+			"ac:image",
+			struct {
+				Width      string
+				Height     string
+				Title      string
+				Alt        string
+				Attachment string
+				Url        string
+			}{
+				"",
+				"",
+				string(n.Title),
+				string(nodeToHTMLText(n, source)),
+				attachments[0].Filename,
+				"",
+			},
+		)
+	}
+
+	if err != nil {
+		return ast.WalkStop, err
+	}
+
+	return ast.WalkSkipChildren, nil
+}
+
+// https://github.com/yuin/goldmark/blob/c446c414ef3a41fb562da0ae5badd18f1502c42f/renderer/html/html.go
+func nodeToHTMLText(n ast.Node, source []byte) []byte {
+	var buf bytes.Buffer
+	for c := n.FirstChild(); c != nil; c = c.NextSibling() {
+		if s, ok := c.(*ast.String); ok && s.IsCode() {
+			buf.Write(s.Text(source))
+		} else if !c.HasChildren() {
+			buf.Write(util.EscapeHTML(c.Text(source)))
+		} else {
+			buf.Write(nodeToHTMLText(c, source))
+		}
+	}
+	return buf.Bytes()
+}
diff --git a/pkg/mark/renderer/link.go b/pkg/mark/renderer/link.go
new file mode 100644
index 0000000..9c77b36
--- /dev/null
+++ b/pkg/mark/renderer/link.go
@@ -0,0 +1,89 @@
+package renderer
+
+import (
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/renderer"
+	"github.com/yuin/goldmark/renderer/html"
+	"github.com/yuin/goldmark/util"
+)
+
+type ConfluenceLinkRenderer struct {
+	html.Config
+}
+
+// NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
+func NewConfluenceLinkRenderer(opts ...html.Option) renderer.NodeRenderer {
+	return &ConfluenceLinkRenderer{
+		Config: html.NewConfig(),
+	}
+}
+
+// RegisterFuncs implements NodeRenderer.RegisterFuncs .
+func (r *ConfluenceLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(ast.KindLink, r.renderLink)
+}
+
+// renderLink renders links specifically for confluence
+func (r *ConfluenceLinkRenderer) renderLink(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	if string(node.(*ast.Link).Destination[0:3]) == "ac:" {
+		if entering {
+			_, err := writer.Write([]byte("<ac:link><ri:page ri:content-title=\""))
+			if err != nil {
+				return ast.WalkStop, err
+			}
+
+			if len(node.(*ast.Link).Destination) < 4 {
+				_, err := writer.Write(node.FirstChild().Text(source))
+				if err != nil {
+					return ast.WalkStop, err
+				}
+			} else {
+				_, err := writer.Write(node.(*ast.Link).Destination[3:])
+				if err != nil {
+					return ast.WalkStop, err
+				}
+
+			}
+			_, err = writer.Write([]byte("\"/><ac:plain-text-link-body><![CDATA["))
+			if err != nil {
+				return ast.WalkStop, err
+			}
+
+			_, err = writer.Write(node.FirstChild().Text(source))
+			if err != nil {
+				return ast.WalkStop, err
+			}
+
+			_, err = writer.Write([]byte("]]></ac:plain-text-link-body></ac:link>"))
+			if err != nil {
+				return ast.WalkStop, err
+			}
+		}
+		return ast.WalkSkipChildren, nil
+	}
+	return r.goldmarkRenderLink(writer, source, node, entering)
+}
+
+// goldmarkRenderLink is the default renderLink implementation from https://github.com/yuin/goldmark/blob/9d6f314b99ca23037c93d76f248be7b37de6220a/renderer/html/html.go#L552
+func (r *ConfluenceLinkRenderer) goldmarkRenderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	n := node.(*ast.Link)
+	if entering {
+		_, _ = w.WriteString("<a href=\"")
+		if r.Unsafe || !html.IsDangerousURL(n.Destination) {
+			_, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
+		}
+		_ = w.WriteByte('"')
+		if n.Title != nil {
+			_, _ = w.WriteString(` title="`)
+			r.Writer.Write(w, n.Title)
+			_ = w.WriteByte('"')
+		}
+		if n.Attributes() != nil {
+			html.RenderAttributes(w, n, html.LinkAttributeFilter)
+		}
+		_ = w.WriteByte('>')
+	} else {
+		_, _ = w.WriteString("</a>")
+	}
+	return ast.WalkContinue, nil
+}


Test patch:
diff --git a/pkg/mark/attachment_test.go b/pkg/mark/attachment/attachment_test.go
similarity index 99%
rename from pkg/mark/attachment_test.go
rename to pkg/mark/attachment/attachment_test.go
index 714a6a4..683b0f4 100644
--- a/pkg/mark/attachment_test.go
+++ b/pkg/mark/attachment/attachment_test.go
@@ -1,4 +1,4 @@
-package mark
+package attachment
 
 import (
 	"bytes"
diff --git a/pkg/mark/mermaid_test.go b/pkg/mark/mermaid/mermaid_test.go
similarity index 88%
rename from pkg/mark/mermaid_test.go
rename to pkg/mark/mermaid/mermaid_test.go
index 072ce93..0d34a37 100644
--- a/pkg/mark/mermaid_test.go
+++ b/pkg/mark/mermaid/mermaid_test.go
@@ -1,9 +1,10 @@
-package mark
+package mermaid
 
 import (
 	"fmt"
 	"testing"
 
+	"github.com/kovetskiy/mark/pkg/mark/attachment"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -12,10 +13,10 @@ func TestExtractMermaidImage(t *testing.T) {
 		name     string
 		markdown []byte
 		scale    float64
-		want     Attachment
+		want     attachment.Attachment
 		wantErr  assert.ErrorAssertionFunc
 	}{
-		{"example", []byte("graph TD;\n A-->B;"), 1.0, Attachment{
+		{"example", []byte("graph TD;\n A-->B;"), 1.0, attachment.Attachment{
 			// This is only the PNG Magic Header
 			FileBytes: []byte{0x89, 0x50, 0x4e, 0x47, 0xd, 0xa, 0x1a, 0xa},
 			Filename:  "example.png",
@@ -30,7 +31,7 @@ func TestExtractMermaidImage(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got, err := processMermaidLocally(tt.name, tt.markdown, tt.scale)
+			got, err := ProcessMermaidLocally(tt.name, tt.markdown, tt.scale)
 			if !tt.wantErr(t, err, fmt.Sprintf("processMermaidLocally(%v, %v)", tt.name, string(tt.markdown))) {
 				return
 			}