mirror of
https://github.com/kovetskiy/mark.git
synced 2025-04-24 05:42:40 +08:00
chore: Migrate to a goldmark extension
This commit is contained in:
parent
10d0778adf
commit
f57b4245f9
9
main.go
9
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,
|
||||
|
@ -1,4 +1,4 @@
|
||||
package mark
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"bytes"
|
@ -1,4 +1,4 @@
|
||||
package mark
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"bytes"
|
@ -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{},
|
||||
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()
|
||||
}
|
||||
|
@ -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,
|
@ -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
|
||||
}
|
172
pkg/mark/renderer/blockquote.go
Normal file
172
pkg/mark/renderer/blockquote.go
Normal file
@ -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
|
||||
}
|
77
pkg/mark/renderer/codeblock.go
Normal file
77
pkg/mark/renderer/codeblock.go
Normal file
@ -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
|
||||
}
|
187
pkg/mark/renderer/fencedcodeblock.go
Normal file
187
pkg/mark/renderer/fencedcodeblock.go
Normal file
@ -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
|
||||
}
|
57
pkg/mark/renderer/heading.go
Normal file
57
pkg/mark/renderer/heading.go
Normal file
@ -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
|
||||
}
|
104
pkg/mark/renderer/htmlblock.go
Normal file
104
pkg/mark/renderer/htmlblock.go
Normal file
@ -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
|
||||
}
|
118
pkg/mark/renderer/image.go
Normal file
118
pkg/mark/renderer/image.go
Normal file
@ -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()
|
||||
}
|
89
pkg/mark/renderer/link.go
Normal file
89
pkg/mark/renderer/link.go
Normal file
@ -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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user