Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

renderer: add support for extending node renderers #16

Merged
merged 4 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:
- main

env:
go_version: 1.18.x
go_version: 1.21.x

jobs:
lint:
Expand All @@ -24,7 +24,7 @@ jobs:
- name: Run linters
uses: golangci/golangci-lint-action@v3
with:
version: v1.52.2
version: v1.56.0

test:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/teekennedy/goldmark-markdown

go 1.18
go 1.21

require (
github.com/rhysd/go-fakeio v1.0.0
Expand Down
170 changes: 95 additions & 75 deletions renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bytes"
"fmt"
"io"
"sync"

"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
Expand All @@ -14,8 +15,10 @@ import (
// NewRenderer returns a new markdown Renderer that is configured by default values.
func NewRenderer(options ...Option) *Renderer {
r := &Renderer{
config: NewConfig(),
rc: renderContext{},
config: NewConfig(),
rc: renderContext{},
maxKind: 20, // a random number slightly larger than the number of default ast kinds
nodeRendererFuncsTmp: map[ast.NodeKind]renderer.NodeRendererFunc{},
}
for _, opt := range options {
opt.SetMarkdownOption(r.config)
Expand All @@ -25,10 +28,16 @@ func NewRenderer(options ...Option) *Renderer {

// Renderer is an implementation of renderer.Renderer that renders nodes as Markdown
type Renderer struct {
config *Config
rc renderContext
config *Config
rc renderContext
nodeRendererFuncsTmp map[ast.NodeKind]renderer.NodeRendererFunc
maxKind int
nodeRendererFuncs []nodeRenderer
initSync sync.Once
}

var _ renderer.Renderer = &Renderer{}

// AddOptions implements renderer.Renderer.AddOptions
func (r *Renderer) AddOptions(opts ...renderer.Option) {
config := renderer.NewConfig()
Expand All @@ -38,64 +47,75 @@ func (r *Renderer) AddOptions(opts ...renderer.Option) {
for name, value := range config.Options {
r.config.SetOption(name, value)
}
// TODO handle any config.NodeRenderers set by opts

// handle any config.NodeRenderers set by opts
config.NodeRenderers.Sort()
l := len(config.NodeRenderers)
for i := l - 1; i >= 0; i-- {
v := config.NodeRenderers[i]
nr, _ := v.Value.(renderer.NodeRenderer)
nr.RegisterFuncs(r)
}
}

func (r *Renderer) Register(kind ast.NodeKind, fun renderer.NodeRendererFunc) {
r.nodeRendererFuncsTmp[kind] = fun
if int(kind) > r.maxKind {
r.maxKind = int(kind)
}
}

// Render implements renderer.Renderer.Render
func (r *Renderer) Render(w io.Writer, source []byte, n ast.Node) error {
r.rc = newRenderContext(w, source, r.config)
/* TODO
reg.Register(ast.KindString, r.renderString)
*/
r.initSync.Do(func() {
r.nodeRendererFuncs = make([]nodeRenderer, r.maxKind+1)
// add default functions
// blocks
r.nodeRendererFuncs[ast.KindDocument] = r.renderBlockSeparator
r.nodeRendererFuncs[ast.KindHeading] = r.chainRenderers(r.renderBlockSeparator, r.renderHeading)
r.nodeRendererFuncs[ast.KindBlockquote] = r.chainRenderers(r.renderBlockSeparator, r.renderBlockquote)
r.nodeRendererFuncs[ast.KindCodeBlock] = r.chainRenderers(r.renderBlockSeparator, r.renderCodeBlock)
r.nodeRendererFuncs[ast.KindFencedCodeBlock] = r.chainRenderers(r.renderBlockSeparator, r.renderFencedCodeBlock)
r.nodeRendererFuncs[ast.KindHTMLBlock] = r.chainRenderers(r.renderBlockSeparator, r.renderHTMLBlock)
r.nodeRendererFuncs[ast.KindList] = r.chainRenderers(r.renderBlockSeparator, r.renderList)
r.nodeRendererFuncs[ast.KindListItem] = r.chainRenderers(r.renderBlockSeparator, r.renderListItem)
r.nodeRendererFuncs[ast.KindParagraph] = r.renderBlockSeparator
r.nodeRendererFuncs[ast.KindTextBlock] = r.renderBlockSeparator
r.nodeRendererFuncs[ast.KindThematicBreak] = r.chainRenderers(r.renderBlockSeparator, r.renderThematicBreak)

// inlines
r.nodeRendererFuncs[ast.KindAutoLink] = r.renderAutoLink
r.nodeRendererFuncs[ast.KindCodeSpan] = r.renderCodeSpan
r.nodeRendererFuncs[ast.KindEmphasis] = r.renderEmphasis
r.nodeRendererFuncs[ast.KindImage] = r.renderImage
r.nodeRendererFuncs[ast.KindLink] = r.renderLink
r.nodeRendererFuncs[ast.KindRawHTML] = r.renderRawHTML
r.nodeRendererFuncs[ast.KindText] = r.renderText
// TODO: add KindString
// r.nodeRendererFuncs[ast.KindString] = r.renderString

for kind, fun := range r.nodeRendererFuncsTmp {
r.nodeRendererFuncs[kind] = r.transform(fun)
}
r.nodeRendererFuncsTmp = nil
})
teekennedy marked this conversation as resolved.
Show resolved Hide resolved
return ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
return r.getRenderer(n)(n, entering), r.rc.writer.Err()
return r.nodeRendererFuncs[n.Kind()](n, entering), r.rc.writer.Err()
teekennedy marked this conversation as resolved.
Show resolved Hide resolved
})
}

// nodeRenderer is a markdown node renderer func.
type nodeRenderer func(ast.Node, bool) ast.WalkStatus

func (r *Renderer) getRenderer(node ast.Node) nodeRenderer {
renderers := []nodeRenderer{}
switch node.Type() {
case ast.TypeBlock:
renderers = append(renderers, r.renderBlockSeparator)
}
switch node.Kind() {
case ast.KindAutoLink:
renderers = append(renderers, r.renderAutoLink)
case ast.KindHeading:
renderers = append(renderers, r.renderHeading)
case ast.KindBlockquote:
renderers = append(renderers, r.renderBlockquote)
case ast.KindCodeBlock:
renderers = append(renderers, r.renderCodeBlock)
case ast.KindCodeSpan:
renderers = append(renderers, r.renderCodeSpan)
case ast.KindEmphasis:
renderers = append(renderers, r.renderEmphasis)
case ast.KindThematicBreak:
renderers = append(renderers, r.renderThematicBreak)
case ast.KindFencedCodeBlock:
renderers = append(renderers, r.renderFencedCodeBlock)
case ast.KindHTMLBlock:
renderers = append(renderers, r.renderHTMLBlock)
case ast.KindImage:
renderers = append(renderers, r.renderImage)
case ast.KindList:
renderers = append(renderers, r.renderList)
case ast.KindListItem:
renderers = append(renderers, r.renderListItem)
case ast.KindRawHTML:
renderers = append(renderers, r.renderRawHTML)
case ast.KindText:
renderers = append(renderers, r.renderText)
case ast.KindLink:
renderers = append(renderers, r.renderLink)
// transform wraps a renderer.NodeRendererFunc to match the nodeRenderer function signature
func (r *Renderer) transform(fn renderer.NodeRendererFunc) nodeRenderer {
return func(n ast.Node, entering bool) ast.WalkStatus {
status, _ := fn(r.rc.writer, r.rc.source, n, entering)
return status
}
return r.chainRenderers(renderers...)
}

// nodeRenderer is a markdown node renderer func.
type nodeRenderer func(ast.Node, bool) ast.WalkStatus

func (r *Renderer) chainRenderers(renderers ...nodeRenderer) nodeRenderer {
return func(node ast.Node, entering bool) ast.WalkStatus {
var walkStatus ast.WalkStatus
Expand Down Expand Up @@ -126,10 +146,10 @@ func (r *Renderer) renderBlockSeparator(node ast.Node, entering bool) ast.WalkSt
func (r *Renderer) renderAutoLink(node ast.Node, entering bool) ast.WalkStatus {
n := node.(*ast.AutoLink)
if entering {
r.rc.writer.Write([]byte("<"))
r.rc.writer.Write(n.URL(r.rc.source))
r.rc.writer.WriteBytes([]byte("<"))
r.rc.writer.WriteBytes(n.URL(r.rc.source))
} else {
r.rc.writer.Write([]byte(">"))
r.rc.writer.WriteBytes([]byte(">"))
}
return ast.WalkContinue
}
Expand Down Expand Up @@ -162,15 +182,15 @@ func (r *Renderer) renderHeading(node ast.Node, entering bool) ast.WalkStatus {

func (r *Renderer) renderATXHeading(node *ast.Heading, entering bool) ast.WalkStatus {
if entering {
r.rc.writer.Write(bytes.Repeat([]byte("#"), node.Level))
r.rc.writer.WriteBytes(bytes.Repeat([]byte("#"), node.Level))
// Only print space after heading if non-empty
if node.HasChildren() {
r.rc.writer.Write([]byte(" "))
r.rc.writer.WriteBytes([]byte(" "))
}
} else {
if r.config.HeadingStyle == HeadingStyleATXSurround {
r.rc.writer.Write([]byte(" "))
r.rc.writer.Write(bytes.Repeat([]byte("#"), node.Level))
r.rc.writer.WriteBytes([]byte(" "))
r.rc.writer.WriteBytes(bytes.Repeat([]byte("#"), node.Level))
}
}
return ast.WalkContinue
Expand All @@ -193,8 +213,8 @@ func (r *Renderer) renderSetextHeading(node *ast.Heading, entering bool) ast.Wal
}
}
}
r.rc.writer.Write([]byte("\n"))
r.rc.writer.Write(bytes.Repeat(underlineChar, underlineWidth))
r.rc.writer.WriteBytes([]byte("\n"))
r.rc.writer.WriteBytes(bytes.Repeat(underlineChar, underlineWidth))
return ast.WalkContinue
}

Expand All @@ -208,7 +228,7 @@ func (r *Renderer) renderThematicBreak(node ast.Node, entering bool) ast.WalkSta
} else {
breakLen = int(r.config.ThematicBreakLength)
}
r.rc.writer.Write(bytes.Repeat(breakChar, breakLen))
r.rc.writer.WriteBytes(bytes.Repeat(breakChar, breakLen))
}
return ast.WalkContinue
}
Expand All @@ -225,10 +245,10 @@ func (r *Renderer) renderCodeBlock(node ast.Node, entering bool) ast.WalkStatus

func (r *Renderer) renderFencedCodeBlock(node ast.Node, entering bool) ast.WalkStatus {
n := node.(*ast.FencedCodeBlock)
r.rc.writer.Write([]byte("```"))
r.rc.writer.WriteBytes([]byte("```"))
if entering {
if info := n.Info; info != nil {
r.rc.writer.Write(info.Text(r.rc.source))
r.rc.writer.WriteBytes(info.Text(r.rc.source))
}
r.rc.writer.FlushLine()
r.renderLines(node, entering)
Expand Down Expand Up @@ -295,7 +315,7 @@ func (r *Renderer) renderText(node ast.Node, entering bool) ast.WalkStatus {
if entering {
text := n.Text(r.rc.source)

r.rc.writer.Write(text)
r.rc.writer.WriteBytes(text)
if n.SoftLineBreak() {
r.rc.writer.EndLine()
}
Expand All @@ -307,7 +327,7 @@ func (r *Renderer) renderSegments(segments *text.Segments, asLines bool) {
for i := 0; i < segments.Len(); i++ {
segment := segments.At(i)
value := segment.Value(r.rc.source)
r.rc.writer.Write(value)
r.rc.writer.WriteBytes(value)
if asLines {
r.rc.writer.FlushLine()
}
Expand All @@ -330,40 +350,40 @@ func (r *Renderer) renderLink(node ast.Node, entering bool) ast.WalkStatus {
func (r *Renderer) renderImage(node ast.Node, entering bool) ast.WalkStatus {
n := node.(*ast.Image)
if entering {
r.rc.writer.Write([]byte("!"))
r.rc.writer.WriteBytes([]byte("!"))
}
return r.renderLinkCommon(n.Title, n.Destination, entering)
}

func (r *Renderer) renderLinkCommon(title, destination []byte, entering bool) ast.WalkStatus {
if entering {
r.rc.writer.Write([]byte("["))
r.rc.writer.WriteBytes([]byte("["))
} else {
r.rc.writer.Write([]byte("]("))
r.rc.writer.Write(destination)
r.rc.writer.WriteBytes([]byte("]("))
r.rc.writer.WriteBytes(destination)
if len(title) > 0 {
r.rc.writer.Write([]byte(" \""))
r.rc.writer.Write(title)
r.rc.writer.Write([]byte("\""))
r.rc.writer.WriteBytes([]byte(" \""))
r.rc.writer.WriteBytes(title)
r.rc.writer.WriteBytes([]byte("\""))
}
r.rc.writer.Write([]byte(")"))
r.rc.writer.WriteBytes([]byte(")"))
}
return ast.WalkContinue
}

func (r *Renderer) renderCodeSpan(node ast.Node, entering bool) ast.WalkStatus {
if bytes.Count(node.Text(r.rc.source), []byte("`"))%2 != 0 {
r.rc.writer.Write([]byte("``"))
r.rc.writer.WriteBytes([]byte("``"))
} else {
r.rc.writer.Write([]byte("`"))
r.rc.writer.WriteBytes([]byte("`"))
}

return ast.WalkContinue
}

func (r *Renderer) renderEmphasis(node ast.Node, entering bool) ast.WalkStatus {
n := node.(*ast.Emphasis)
r.rc.writer.Write(bytes.Repeat([]byte{'*'}, n.Level))
r.rc.writer.WriteBytes(bytes.Repeat([]byte{'*'}, n.Level))
return ast.WalkContinue
}

Expand Down
20 changes: 19 additions & 1 deletion renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
Expand Down Expand Up @@ -59,9 +60,26 @@ func TestRenderError(t *testing.T) {
assert.Equal(t, err, result)
}

// TestCustomRenderers tests that the renderer uses any config.NodeRenderers defined by the user
func TestCustomRenderers(t *testing.T) {
md := goldmark.New(
goldmark.WithRenderer(NewRenderer()),
goldmark.WithParserOptions(parser.WithASTTransformers(util.Prioritized(&transformer, 0))),
)
buf := bytes.Buffer{}
source := `# My Tasks
- [x] Add support for custom renderers
`

extension.TaskList.Extend(md)
err := md.Convert([]byte(source), &buf)
assert.NoError(t, err)
t.Log(buf.String())
}

// TestRenderedOutput tests that the renderer produces the expected output for all test cases
func TestRenderedOutput(t *testing.T) {
var testCases = []struct {
testCases := []struct {
name string
options []Option
source string
Expand Down
Loading
Loading