From a6d5f14a56a98da57878befdc1e4e4e5d7a5f15b Mon Sep 17 00:00:00 2001 From: Laurel Schmidt Date: Mon, 28 Jul 2025 14:58:10 -0500 Subject: [PATCH 1/3] Add flag to render link definitions in footer --- md/md_renderer.go | 70 ++++++++++++++++++++++++++++++++++++------ md/md_renderer_test.go | 39 ++++++++++++++++------- 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/md/md_renderer.go b/md/md_renderer.go index ebbea004..7971fb5d 100644 --- a/md/md_renderer.go +++ b/md/md_renderer.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io" + "sort" "strings" "github.com/gomarkdown/markdown/ast" @@ -21,6 +22,24 @@ type Renderer struct { listDepth int indentSize int lastNormalText string + + Opts *RendererOptions + + linkcache map[string]bool // cache for link definitions to write in the footer, if renderLinksInFooter is set +} + +type RendererOptions struct { + Flags Flags +} + +type Flags int + +const renderLinksInFooter Flags = 1 << iota + +func (r *Renderer) WithOpts(opts *RendererOptions) { + if opts != nil { + r.Opts = opts + } } // NewRenderer returns a Markdown renderer. @@ -44,7 +63,7 @@ func (r *Renderer) outs(w io.Writer, s string) { func (r *Renderer) doubleSpace(w io.Writer) { // TODO: need to remember number of written bytes - //if out.Len() > 0 { + // if out.Len() > 0 { r.outs(w, "\n") //} } @@ -83,7 +102,7 @@ func (r *Renderer) listItem(w io.Writer, node *ast.ListItem, entering bool) { func (r *Renderer) para(w io.Writer, node *ast.Paragraph, entering bool) { if !entering && r.lastOutputLen > 0 { - var br = "\n\n" + br := "\n\n" // List items don't need the extra line-break. if _, ok := node.Parent.(*ast.ListItem); ok { @@ -279,14 +298,27 @@ func (r *Renderer) link(w io.Writer, node *ast.Link, entering bool) { } else { link := string(escape(node.Destination)) title := string(node.Title) - r.outs(w, "](") - r.outs(w, link) - if len(title) != 0 { - r.outs(w, ` "`) - r.outs(w, title) - r.outs(w, `"`) + if r.Opts == nil || r.Opts.Flags&renderLinksInFooter == 0 { + r.outs(w, "](") + r.outs(w, link) + if len(title) != 0 { + r.outs(w, ` "`) + r.outs(w, title) + r.outs(w, `"`) + } + r.outs(w, ")") + } else { + r.outs(w, "]") + child, _ := ast.GetFirstChild(node).(*ast.Text) + linkdefn := fmt.Sprintf("[%s]: %s", string(escape(child.Leaf.Literal)), link) + if len(title) != 0 { + linkdefn += fmt.Sprintf(" \"%s\"", title) + } + if r.linkcache == nil { + r.linkcache = make(map[string]bool) + } + r.linkcache[linkdefn] = true } - r.outs(w, ")") } } @@ -382,5 +414,23 @@ func (r *Renderer) RenderHeader(w io.Writer, ast ast.Node) { // RenderFooter renders footer func (r *Renderer) RenderFooter(w io.Writer, ast ast.Node) { - // do nothing + if r.Opts != nil && r.Opts.Flags&renderLinksInFooter != 0 { + if r.linkcache == nil { + return + } + + // Extract links so we can write links in a predictable order. + links := make([]string, 0, len(r.linkcache)) + for k := range r.linkcache { + links = append(links, k) + } + // Sort the keys to ensure consistent order. + sort.Strings(links) + + for _, linkdefn := range links { + r.outs(w, "\n") + r.outs(w, linkdefn) + } + r.outs(w, "\n") + } } diff --git a/md/md_renderer_test.go b/md/md_renderer_test.go index 8aa5614f..0a302123 100644 --- a/md/md_renderer_test.go +++ b/md/md_renderer_test.go @@ -9,9 +9,9 @@ import ( ) func TestRenderDocument(t *testing.T) { - var source = []byte("# title\n* aaa\n* bbb\n* ccc") - var input = markdown.Parse(source, nil) - var expected = "# title\n\n* aaa\n* bbb\n* ccc\n\n" + source := []byte("# title\n* aaa\n* bbb\n* ccc") + input := markdown.Parse(source, nil) + expected := "# title\n\n* aaa\n* bbb\n* ccc\n\n" testRendering(t, input, expected) } @@ -64,21 +64,21 @@ func TestRenderImage(t *testing.T) { } func TestRenderCode(t *testing.T) { - var input = &ast.Code{} + input := &ast.Code{} input.Literal = []byte(string("val x : Int = 42")) expected := "`val x : Int = 42`" testRendering(t, input, expected) } func TestRenderCodeBlock(t *testing.T) { - var input = &ast.CodeBlock{Info: []byte(string("scala"))} + input := &ast.CodeBlock{Info: []byte(string("scala"))} input.Literal = []byte(string("val x : Int = 42")) expected := "\n```scala\nval x : Int = 42\n```\n" testRendering(t, input, expected) } func TestRenderParagraph(t *testing.T) { - var input = &ast.Paragraph{} + input := &ast.Paragraph{} ast.AppendChild(input, &ast.Text{Leaf: ast.Leaf{Literal: []byte(string("Hello World !"))}}) expected := "Hello World !\n\n" testRendering(t, input, expected) @@ -101,23 +101,23 @@ func TestRenderCodeWithParagraph(t *testing.T) { } func TestRenderHTMLSpan(t *testing.T) { - var input = &ast.HTMLSpan{} + input := &ast.HTMLSpan{} input.Literal = []byte(string("hello")) expected := "hello" testRendering(t, input, expected) } func TestRenderHTMLBlock(t *testing.T) { - var input = &ast.HTMLBlock{} + input := &ast.HTMLBlock{} input.Literal = []byte(string("hello")) expected := "\nhello\n\n" testRendering(t, input, expected) } func TestRenderList(t *testing.T) { - var source = []byte("* aaa\n* bbb\n* ccc\n* ddd\n") - var input = markdown.Parse(source, nil) - var expected = "* aaa\n* bbb\n* ccc\n* ddd\n\n" + source := []byte("* aaa\n* bbb\n* ccc\n* ddd\n") + input := markdown.Parse(source, nil) + expected := "* aaa\n* bbb\n* ccc\n* ddd\n\n" testRendering(t, input, expected) source = []byte("+ aaa\n+ bbb\n+ ccc\n+ ddd\n") @@ -158,3 +158,20 @@ func testRendering(t *testing.T, input ast.Node, expected string) { t.Errorf("[%s] is not equal to [%s]", result, expected) } } + +func TestRenderLinksInFooter(t *testing.T) { + source := []byte("This is an [example](https://example.com) and another [website](https://github.com).") + input := markdown.Parse(source, nil) + flags := renderLinksInFooter + expected := "This is an [example] and another [website].\n\n\n[example]: https://example.com\n[website]: https://github.com\n" + testRenderingWithFlags(t, input, expected, flags) +} + +func testRenderingWithFlags(t *testing.T, input ast.Node, expected string, flags Flags) { + renderer := NewRenderer() + renderer.WithOpts(&RendererOptions{Flags: flags}) + result := string(markdown.Render(input, renderer)) + if strings.Compare(result, expected) != 0 { + t.Errorf("[%s] is not equal to [%s]", result, expected) + } +} From a77f4be6230699e1d6fb39f6f31c224e67e674fe Mon Sep 17 00:00:00 2001 From: Laurel Schmidt Date: Tue, 29 Jul 2025 09:35:43 -0500 Subject: [PATCH 2/3] Update style of passing configuration to markdown renderer --- md/md_renderer.go | 31 +++++++++++++++++++++---------- md/md_renderer_test.go | 3 +-- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/md/md_renderer.go b/md/md_renderer.go index 7971fb5d..305adcd4 100644 --- a/md/md_renderer.go +++ b/md/md_renderer.go @@ -23,12 +23,12 @@ type Renderer struct { indentSize int lastNormalText string - Opts *RendererOptions + C *RendererConfig linkcache map[string]bool // cache for link definitions to write in the footer, if renderLinksInFooter is set } -type RendererOptions struct { +type RendererConfig struct { Flags Flags } @@ -36,18 +36,29 @@ type Flags int const renderLinksInFooter Flags = 1 << iota -func (r *Renderer) WithOpts(opts *RendererOptions) { - if opts != nil { - r.Opts = opts - } -} +type RendererOpt func(c *RendererConfig) // NewRenderer returns a Markdown renderer. -func NewRenderer() *Renderer { +func NewRenderer(opts ...RendererOpt) *Renderer { + c := &RendererConfig{} + for _, opt := range opts { + opt(c) + } return &Renderer{ orderedListCounter: map[int]int{}, paragraph: map[int]bool{}, indentSize: 4, + C: c, + } +} + +func WithRenderInFooter(renderInFooter bool) RendererOpt { + return func(c *RendererConfig) { + if renderInFooter { + c.Flags |= renderLinksInFooter + } else { + c.Flags &^= renderLinksInFooter + } } } @@ -298,7 +309,7 @@ func (r *Renderer) link(w io.Writer, node *ast.Link, entering bool) { } else { link := string(escape(node.Destination)) title := string(node.Title) - if r.Opts == nil || r.Opts.Flags&renderLinksInFooter == 0 { + if r.C == nil || r.C.Flags&renderLinksInFooter == 0 { r.outs(w, "](") r.outs(w, link) if len(title) != 0 { @@ -414,7 +425,7 @@ func (r *Renderer) RenderHeader(w io.Writer, ast ast.Node) { // RenderFooter renders footer func (r *Renderer) RenderFooter(w io.Writer, ast ast.Node) { - if r.Opts != nil && r.Opts.Flags&renderLinksInFooter != 0 { + if r.C != nil && r.C.Flags&renderLinksInFooter != 0 { if r.linkcache == nil { return } diff --git a/md/md_renderer_test.go b/md/md_renderer_test.go index 0a302123..955298dd 100644 --- a/md/md_renderer_test.go +++ b/md/md_renderer_test.go @@ -168,8 +168,7 @@ func TestRenderLinksInFooter(t *testing.T) { } func testRenderingWithFlags(t *testing.T, input ast.Node, expected string, flags Flags) { - renderer := NewRenderer() - renderer.WithOpts(&RendererOptions{Flags: flags}) + renderer := NewRenderer(WithRenderInFooter(true)) result := string(markdown.Render(input, renderer)) if strings.Compare(result, expected) != 0 { t.Errorf("[%s] is not equal to [%s]", result, expected) From 3c94e099a5eae1ac662fba2d97b7742d2dffbcb8 Mon Sep 17 00:00:00 2001 From: Laurel Schmidt Date: Tue, 29 Jul 2025 09:44:33 -0500 Subject: [PATCH 3/3] Refactor link rendering logic and update tests for rendering links in footer --- md/md_renderer.go | 24 +++++++++++++----------- md/md_renderer_test.go | 24 +++++++----------------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/md/md_renderer.go b/md/md_renderer.go index 305adcd4..4d01a124 100644 --- a/md/md_renderer.go +++ b/md/md_renderer.go @@ -318,18 +318,20 @@ func (r *Renderer) link(w io.Writer, node *ast.Link, entering bool) { r.outs(w, `"`) } r.outs(w, ")") - } else { - r.outs(w, "]") - child, _ := ast.GetFirstChild(node).(*ast.Text) - linkdefn := fmt.Sprintf("[%s]: %s", string(escape(child.Leaf.Literal)), link) - if len(title) != 0 { - linkdefn += fmt.Sprintf(" \"%s\"", title) - } - if r.linkcache == nil { - r.linkcache = make(map[string]bool) - } - r.linkcache[linkdefn] = true + return } + + r.outs(w, "]") + child, _ := ast.GetFirstChild(node).(*ast.Text) + linkdefn := fmt.Sprintf("[%s]: %s", string(escape(child.Leaf.Literal)), link) + if len(title) != 0 { + linkdefn += fmt.Sprintf(" \"%s\"", title) + } + if r.linkcache == nil { + r.linkcache = make(map[string]bool) + } + r.linkcache[linkdefn] = true + } } diff --git a/md/md_renderer_test.go b/md/md_renderer_test.go index 955298dd..8302f1bf 100644 --- a/md/md_renderer_test.go +++ b/md/md_renderer_test.go @@ -149,26 +149,16 @@ func TestRenderList(t *testing.T) { input = markdown.Parse(source, nil) expected = "* aaa\n * aaa1\n * aaa2\n\n* bbb\n* ccc\n* ddd\n\n" testRendering(t, input, expected) -} -func testRendering(t *testing.T, input ast.Node, expected string) { - renderer := NewRenderer() - result := string(markdown.Render(input, renderer)) - if strings.Compare(result, expected) != 0 { - t.Errorf("[%s] is not equal to [%s]", result, expected) - } -} - -func TestRenderLinksInFooter(t *testing.T) { - source := []byte("This is an [example](https://example.com) and another [website](https://github.com).") - input := markdown.Parse(source, nil) - flags := renderLinksInFooter - expected := "This is an [example] and another [website].\n\n\n[example]: https://example.com\n[website]: https://github.com\n" - testRenderingWithFlags(t, input, expected, flags) + source = []byte("This is an [example](https://example.com) and another [website](https://github.com).") + input = markdown.Parse(source, nil) + rendererOpts := []RendererOpt{WithRenderInFooter(true)} + expected = "This is an [example] and another [website].\n\n\n[example]: https://example.com\n[website]: https://github.com\n" + testRendering(t, input, expected, rendererOpts...) } -func testRenderingWithFlags(t *testing.T, input ast.Node, expected string, flags Flags) { - renderer := NewRenderer(WithRenderInFooter(true)) +func testRendering(t *testing.T, input ast.Node, expected string, opts ...RendererOpt) { + renderer := NewRenderer(opts...) result := string(markdown.Render(input, renderer)) if strings.Compare(result, expected) != 0 { t.Errorf("[%s] is not equal to [%s]", result, expected)