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 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/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/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( - // (|-) (collapse||\d)* (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{}, + Attachments: []attachment.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) +func (c *ConfluenceExtension) Extend(m goldmark.Markdown) { - // 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) + 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), + )) + + 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 (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 - } - } - - 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, "&", "&") - - 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 -} - -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) - -} - -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 -} - -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/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 } 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, "&", "&") + + 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 +}