diff --git a/go.mod b/go.mod index 7e7b81f..9add194 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/leodido/go-conventionalcommits v0.12.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 - github.com/teekennedy/goldmark-markdown v0.3.0 github.com/yuin/goldmark v1.7.4 ) diff --git a/go.sum b/go.sum index 65568ec..b5f7d57 100644 --- a/go.sum +++ b/go.sum @@ -67,8 +67,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rhysd/go-fakeio v1.0.0 h1:+TjiKCOs32dONY7DaoVz/VPOdvRkPfBkEyUDIpM8FQY= -github.com/rhysd/go-fakeio v1.0.0/go.mod h1:joYxF906trVwp2JLrE4jlN7A0z6wrz8O6o1UjarbFzE= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -89,8 +87,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/teekennedy/goldmark-markdown v0.3.0 h1:ik9/biVGCwGWFg8dQ3KVm2pQ/wiiG0whYiUcz9xH0W8= -github.com/teekennedy/goldmark-markdown v0.3.0/go.mod h1:kMhDz8La77A9UHvJGsxejd0QUflN9sS+QXCqnhmxmNo= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go index 6004829..1d0fd67 100644 --- a/internal/changelog/changelog.go +++ b/internal/changelog/changelog.go @@ -5,10 +5,8 @@ import ( _ "embed" "html/template" "log" - "log/slog" "github.com/apricote/releaser-pleaser/internal/commitparser" - "github.com/apricote/releaser-pleaser/internal/markdown" ) var ( @@ -26,7 +24,7 @@ func init() { } } -func NewChangelogEntry(logger *slog.Logger, commits []commitparser.AnalyzedCommit, version, link, prefix, suffix string) (string, error) { +func NewChangelogEntry(commits []commitparser.AnalyzedCommit, version, link, prefix, suffix string) (string, error) { features := make([]commitparser.AnalyzedCommit, 0) fixes := make([]commitparser.AnalyzedCommit, 0) @@ -52,11 +50,5 @@ func NewChangelogEntry(logger *slog.Logger, commits []commitparser.AnalyzedCommi return "", err } - formatted, err := markdown.Format(changelog.String()) - if err != nil { - logger.Warn("failed to format changelog entry, using unformatted", "error", err) - return changelog.String(), nil - } - - return formatted, nil + return changelog.String(), nil } diff --git a/internal/changelog/changelog.md.tpl b/internal/changelog/changelog.md.tpl index 1f7dd42..85482e1 100644 --- a/internal/changelog/changelog.md.tpl +++ b/internal/changelog/changelog.md.tpl @@ -19,4 +19,4 @@ {{- if .Suffix }} {{ .Suffix }} -{{ end }} +{{ end -}} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go index e6582c7..384e30f 100644 --- a/internal/changelog/changelog_test.go +++ b/internal/changelog/changelog_test.go @@ -1,7 +1,6 @@ package changelog import ( - "log/slog" "testing" "github.com/stretchr/testify/assert" @@ -35,7 +34,7 @@ func Test_NewChangelogEntry(t *testing.T) { version: "1.0.0", link: "https://example.com/1.0.0", }, - want: "## [1.0.0](https://example.com/1.0.0)\n", + want: "## [1.0.0](https://example.com/1.0.0)", wantErr: assert.NoError, }, { @@ -51,7 +50,7 @@ func Test_NewChangelogEntry(t *testing.T) { version: "1.0.0", link: "https://example.com/1.0.0", }, - want: "## [1.0.0](https://example.com/1.0.0)\n\n### Features\n\n- Foobar!\n", + want: "## [1.0.0](https://example.com/1.0.0)\n### Features\n\n- Foobar!\n", wantErr: assert.NoError, }, { @@ -67,7 +66,7 @@ func Test_NewChangelogEntry(t *testing.T) { version: "1.0.0", link: "https://example.com/1.0.0", }, - want: "## [1.0.0](https://example.com/1.0.0)\n\n### Bug Fixes\n\n- Foobar!\n", + want: "## [1.0.0](https://example.com/1.0.0)\n### Bug Fixes\n\n- Foobar!\n", wantErr: assert.NoError, }, { @@ -101,7 +100,6 @@ func Test_NewChangelogEntry(t *testing.T) { link: "https://example.com/1.0.0", }, want: `## [1.0.0](https://example.com/1.0.0) - ### Features - Blabla! @@ -129,7 +127,6 @@ func Test_NewChangelogEntry(t *testing.T) { prefix: "### Breaking Changes", }, want: `## [1.0.0](https://example.com/1.0.0) - ### Breaking Changes ### Bug Fixes @@ -153,7 +150,6 @@ func Test_NewChangelogEntry(t *testing.T) { suffix: "### Compatibility\n\nThis version is compatible with flux-compensator v2.2 - v2.9.", }, want: `## [1.0.0](https://example.com/1.0.0) - ### Bug Fixes - Foobar! @@ -168,7 +164,7 @@ This version is compatible with flux-compensator v2.2 - v2.9. for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NewChangelogEntry(slog.Default(), tt.args.analyzedCommits, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix) + got, err := NewChangelogEntry(tt.args.analyzedCommits, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix) if !tt.wantErr(t, err) { return } diff --git a/internal/markdown/extensions/section.go b/internal/markdown/extensions/section.go index c8fdcb7..dcca37d 100644 --- a/internal/markdown/extensions/section.go +++ b/internal/markdown/extensions/section.go @@ -1,13 +1,11 @@ package extensions import ( - "fmt" "regexp" "github.com/yuin/goldmark" gast "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" @@ -78,43 +76,6 @@ func (s *sectionParser) Trigger() []byte { return []byte(sectionTrigger) } -type SectionMarkdownRenderer struct{} - -func NewSectionMarkdownRenderer() renderer.NodeRenderer { - return &SectionMarkdownRenderer{} -} - -func (s SectionMarkdownRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(ast.KindSection, s.renderSection) -} - -func (s SectionMarkdownRenderer) renderSection(w util.BufWriter, _ []byte, node gast.Node, enter bool) (gast.WalkStatus, error) { - n := node.(*ast.Section) - - if enter { - // Add blank previous line if applicable - if node.PreviousSibling() != nil && node.HasBlankPreviousLines() { - if _, err := w.WriteRune('\n'); err != nil { - return gast.WalkStop, err - } - } - - if _, err := fmt.Fprintf(w, SectionStartFormat, n.Name); err != nil { - return gast.WalkStop, fmt.Errorf(": %w", err) - } - } else { - if _, err := fmt.Fprintf(w, SectionEndFormat, n.Name); err != nil { - return gast.WalkStop, fmt.Errorf(": %w", err) - } - - if _, err := w.WriteRune('\n'); err != nil { - return gast.WalkStop, err - } - } - - return gast.WalkContinue, nil -} - type section struct{} // Section is an extension that allow you to use group content under a shared parent ast node. @@ -124,7 +85,4 @@ func (e *section) Extend(m goldmark.Markdown) { m.Parser().AddOptions(parser.WithBlockParsers( util.Prioritized(NewSectionParser(), 0), )) - m.Renderer().AddOptions(renderer.WithNodeRenderers( - util.Prioritized(NewSectionMarkdownRenderer(), 500), - )) } diff --git a/internal/markdown/goldmark.go b/internal/markdown/goldmark.go index 456fc55..b78f089 100644 --- a/internal/markdown/goldmark.go +++ b/internal/markdown/goldmark.go @@ -1,36 +1,17 @@ package markdown import ( - "bytes" - - markdown "github.com/teekennedy/goldmark-markdown" "github.com/yuin/goldmark" - "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/util" "github.com/apricote/releaser-pleaser/internal/markdown/extensions" + "github.com/apricote/releaser-pleaser/internal/markdown/renderer/markdown" ) func New() goldmark.Markdown { return goldmark.New( goldmark.WithExtensions(extensions.Section), - goldmark.WithParserOptions(parser.WithASTTransformers( - util.Prioritized(&newLineTransformer{}, 1), - )), - goldmark.WithRenderer(markdown.NewRenderer()), + goldmark.WithRenderer(renderer.NewRenderer(renderer.WithNodeRenderers(util.Prioritized(markdown.NewRenderer(), 1)))), ) } - -// Format the Markdown document in a style mimicking Prettier. This is done for compatibility with other tools -// users might have installed in their IDE. This does not guarantee that the output matches Prettier exactly. -func Format(input string) (string, error) { - var buf bytes.Buffer - buf.Grow(len(input)) - - err := New().Convert([]byte(input), &buf) - if err != nil { - return "", err - } - - return buf.String(), nil -} diff --git a/internal/markdown/prettier.go b/internal/markdown/prettier.go deleted file mode 100644 index 914f1a9..0000000 --- a/internal/markdown/prettier.go +++ /dev/null @@ -1,31 +0,0 @@ -package markdown - -import ( - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/text" -) - -type newLineTransformer struct{} - -var _ parser.ASTTransformer = (*newLineTransformer)(nil) // interface compliance - -func (t *newLineTransformer) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) { - // No error can happen as they can only come from the walker function - _ = ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering || node.Type() != ast.TypeBlock { - return ast.WalkContinue, nil - } - - switch node.Kind() { - case ast.KindListItem: - // Do not add empty lines between every list item - break - default: - // Add empty lines between every other block - node.SetBlankPreviousLines(true) - } - - return ast.WalkContinue, nil - }) -} diff --git a/internal/markdown/renderer/markdown/LICENSE b/internal/markdown/renderer/markdown/LICENSE new file mode 100644 index 0000000..0edc41c --- /dev/null +++ b/internal/markdown/renderer/markdown/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Rolf Lewis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/internal/markdown/renderer/markdown/README.md b/internal/markdown/renderer/markdown/README.md new file mode 100644 index 0000000..c84792a --- /dev/null +++ b/internal/markdown/renderer/markdown/README.md @@ -0,0 +1,4 @@ +This directory is a vendored copy of https://github.com/RolfLewis/goldmark-down/blob/main/markdown.go. +The original repository is set to a `main` package which can not be imported. + +It is under the MIT license. \ No newline at end of file diff --git a/internal/markdown/renderer/markdown/renderer.go b/internal/markdown/renderer/markdown/renderer.go new file mode 100644 index 0000000..69b2883 --- /dev/null +++ b/internal/markdown/renderer/markdown/renderer.go @@ -0,0 +1,836 @@ +package markdown + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" + + "github.com/yuin/goldmark/ast" + exast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" + + rpexast "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast" +) + +type blockState struct { + node ast.Node + fresh bool +} + +type listState struct { + marker byte + ordered bool + index int +} + +type Renderer struct { + listStack []listState + openBlocks []blockState + prefixStack []string + prefix []byte + atNewline bool +} + +// NewRenderer returns a new Renderer with given options. +func NewRenderer() renderer.NodeRenderer { + r := &Renderer{} + + return r +} + +func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + // default registrations + // blocks + reg.Register(ast.KindDocument, r.renderDocument) + 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.renderList) + reg.Register(ast.KindListItem, r.renderListItem) + reg.Register(ast.KindParagraph, r.renderParagraph) + reg.Register(ast.KindTextBlock, r.renderTextBlock) + reg.Register(ast.KindThematicBreak, r.renderThematicBreak) + + // inlines + reg.Register(ast.KindAutoLink, r.renderAutoLink) + reg.Register(ast.KindCodeSpan, r.renderCodeSpan) + reg.Register(ast.KindEmphasis, r.renderEmphasis) + reg.Register(ast.KindImage, r.renderImage) + reg.Register(ast.KindLink, r.renderLink) + reg.Register(ast.KindRawHTML, r.renderRawHTML) + reg.Register(ast.KindText, r.renderText) + reg.Register(ast.KindString, r.renderString) + + // GFM Extensions + // Tables + reg.Register(exast.KindTable, r.renderTable) + reg.Register(exast.KindTableHeader, r.renderTableHeader) + reg.Register(exast.KindTableRow, r.renderTableRow) + reg.Register(exast.KindTableCell, r.renderTableCell) + // Strikethrough + reg.Register(exast.KindStrikethrough, r.renderStrikethrough) + // Checkbox + reg.Register(exast.KindTaskCheckBox, r.renderTaskCheckBox) + + // releaser-pleaser Extensions + // Section + reg.Register(rpexast.KindSection, r.renderSection) +} + +func (r *Renderer) write(w io.Writer, buf []byte) (int, error) { + written := 0 + for len(buf) > 0 { + if r.atNewline { + if err := r.beginLine(w); err != nil { + return 0, fmt.Errorf(": %w", err) + } + } + + atNewline := false + newline := bytes.IndexByte(buf, '\n') + if newline == -1 { + newline = len(buf) - 1 + } else { + atNewline = true + } + + n, err := w.Write(buf[:newline+1]) + written += n + r.atNewline = n > 0 && atNewline && n == newline+1 + if len(r.openBlocks) != 0 { + r.openBlocks[len(r.openBlocks)-1].fresh = false + } + if err != nil { + return written, fmt.Errorf(": %w", err) + } + buf = buf[n:] + } + return written, nil +} + +func (r *Renderer) beginLine(w io.Writer) error { + if len(r.openBlocks) != 0 { + current := r.openBlocks[len(r.openBlocks)-1] + if current.node.Kind() == ast.KindParagraph && !current.fresh { + return nil + } + } + + n, err := w.Write(r.prefix) + if n != 0 { + r.atNewline = r.prefix[len(r.prefix)-1] == '\n' + } + if err != nil { + return fmt.Errorf(": %w", err) + } + return nil +} + +func (r *Renderer) writeLines(w util.BufWriter, source []byte, lines *text.Segments) error { + for i := 0; i < lines.Len(); i++ { + line := lines.At(i) + if _, err := r.write(w, line.Value(source)); err != nil { + return fmt.Errorf(": %w", err) + } + } + return nil +} + +func (r *Renderer) writeByte(w io.Writer, c byte) error { + if _, err := r.write(w, []byte{c}); err != nil { + return fmt.Errorf(": %w", err) + } + return nil +} + +// WriteString writes a string to an io.Writer, ensuring that appropriate indentation and prefixes are added at the +// beginning of each line. +func (r *Renderer) writeString(w io.Writer, s string) (int, error) { + n, err := r.write(w, []byte(s)) + if err != nil { + return n, fmt.Errorf(": %w", err) + } + return n, nil +} + +// PushIndent adds the specified amount of indentation to the current line prefix. +func (r *Renderer) pushIndent(amount int) { + r.pushPrefix(strings.Repeat(" ", amount)) +} + +// PushPrefix adds the specified string to the current line prefix. +func (r *Renderer) pushPrefix(prefix string) { + r.prefixStack = append(r.prefixStack, prefix) + r.prefix = append(r.prefix, []byte(prefix)...) +} + +// PopPrefix removes the last piece added by a call to PushIndent or PushPrefix from the current line prefix. +func (r *Renderer) popPrefix() { + r.prefix = r.prefix[:len(r.prefix)-len(r.prefixStack[len(r.prefixStack)-1])] + r.prefixStack = r.prefixStack[:len(r.prefixStack)-1] +} + +// OpenBlock ensures that each block begins on a new line, and that blank lines are inserted before blocks as +// indicated by node.HasPreviousBlankLines. +func (r *Renderer) openBlock(w util.BufWriter, _ []byte, node ast.Node) error { + r.openBlocks = append(r.openBlocks, blockState{ + node: node, + fresh: true, + }) + + hasBlankPreviousLines := node.HasBlankPreviousLines() + + // FIXME: standard goldmark table parser doesn't recognize Blank Previous Lines so we'll always add one + if node.Kind() == exast.KindTable { + hasBlankPreviousLines = true + } + + // Work around the fact that the first child of a node notices the same set of preceding blank lines as its parent. + if p := node.Parent(); p != nil && p.FirstChild() == node { + if p.Kind() == ast.KindDocument || p.Kind() == ast.KindListItem || p.HasBlankPreviousLines() { + hasBlankPreviousLines = false + } + } + + if hasBlankPreviousLines { + if err := r.writeByte(w, '\n'); err != nil { + return fmt.Errorf(": %w", err) + } + } + + r.openBlocks[len(r.openBlocks)-1].fresh = true + + return nil +} + +// CloseBlock marks the current block as closed. +func (r *Renderer) closeBlock(w io.Writer) error { + if !r.atNewline { + if err := r.writeByte(w, '\n'); err != nil { + return fmt.Errorf(": %w", err) + } + } + + r.openBlocks = r.openBlocks[:len(r.openBlocks)-1] + return nil +} + +// RenderDocument renders an *ast.Document node to the given BufWriter. +func (r *Renderer) renderDocument(_ util.BufWriter, _ []byte, _ ast.Node, _ bool) (ast.WalkStatus, error) { + r.listStack, r.prefixStack, r.prefix, r.atNewline = nil, nil, nil, false + return ast.WalkContinue, nil +} + +// RenderHeading renders an *ast.Heading node to the given BufWriter. +func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if _, err := r.writeString(w, strings.Repeat("#", node.(*ast.Heading).Level)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if err := r.writeByte(w, ' '); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if err := r.writeByte(w, '\n'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderBlockquote renders an *ast.Blockquote node to the given BufWriter. +func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if _, err := r.writeString(w, "> "); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + r.pushPrefix("> ") + } else { + r.popPrefix() + + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderCodeBlock renders an *ast.CodeBlock node to the given BufWriter. +func (r *Renderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + r.popPrefix() + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil + } + + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // // Each line of a code block needs to be aligned at the same offset, and a code block must start with at least four + // // spaces. To achieve this, we unconditionally add four spaces to the first line of the code block and indent the + // // rest as necessary. + // if _, err := r.writeString(w, " "); err != nil { + // return ast.WalkStop, fmt.Errorf(": %w", err) + // } + + r.pushIndent(4) + if err := r.writeLines(w, source, node.Lines()); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkContinue, nil +} + +// RenderFencedCodeBlock renders an *ast.FencedCodeBlock node to the given BufWriter. +func (r *Renderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil + } + + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + code := node.(*ast.FencedCodeBlock) + + // Write the start of the fenced code block. + fence := []byte("```") + if _, err := r.write(w, fence); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + language := code.Language(source) + if _, err := r.write(w, language); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if err := r.writeByte(w, '\n'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // Write the contents of the fenced code block. + if err := r.writeLines(w, source, node.Lines()); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // Write the end of the fenced code block. + if err := r.beginLine(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if _, err := r.write(w, fence); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if err := r.writeByte(w, '\n'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkContinue, nil +} + +// RenderHTMLBlock renders an *ast.HTMLBlock node to the given BufWriter. +func (r *Renderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil + } + + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // Write the contents of the HTML block. + if err := r.writeLines(w, source, node.Lines()); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // Write the closure line, if any. + html := node.(*ast.HTMLBlock) + if html.HasClosure() { + if _, err := r.write(w, html.ClosureLine.Value(source)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderList renders an *ast.List node to the given BufWriter. +func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + list := node.(*ast.List) + r.listStack = append(r.listStack, listState{ + marker: list.Marker, + ordered: list.IsOrdered(), + index: list.Start, + }) + } else { + r.listStack = r.listStack[:len(r.listStack)-1] + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderListItem renders an *ast.ListItem node to the given BufWriter. +func (r *Renderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + markerWidth := 2 // marker + space + + state := &r.listStack[len(r.listStack)-1] + if state.ordered { + width, err := r.writeString(w, strconv.FormatInt(int64(state.index), 10)) + if err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + state.index++ + markerWidth += width // marker, space, and digits + } + + if _, err := r.write(w, []byte{state.marker, ' '}); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + r.pushIndent(markerWidth) + } else { + r.popPrefix() + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderParagraph renders an *ast.Paragraph node to the given BufWriter. +func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + // A paragraph that follows another paragraph or a blockquote must be preceded by a blank line. + if !node.HasBlankPreviousLines() { + if prev := node.PreviousSibling(); prev != nil && (prev.Kind() == ast.KindParagraph || prev.Kind() == ast.KindBlockquote) { + if err := r.writeByte(w, '\n'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + } + + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderTextBlock renders an *ast.TextBlock node to the given BufWriter. +func (r *Renderer) renderTextBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderThematicBreak renders an *ast.ThematicBreak node to the given BufWriter. +func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil + } + + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // TODO: this prints an extra no line + if _, err := r.writeString(w, "--------"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkContinue, nil +} + +// RenderAutoLink renders an *ast.AutoLink node to the given BufWriter. +func (r *Renderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkContinue, nil + } + + if err := r.writeByte(w, '<'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if _, err := r.write(w, node.(*ast.AutoLink).Label(source)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if err := r.writeByte(w, '>'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) shouldPadCodeSpan(source []byte, node *ast.CodeSpan) bool { + c := node.FirstChild() + if c == nil { + return false + } + + segment := c.(*ast.Text).Segment + text := segment.Value(source) + + var firstChar byte + if len(text) > 0 { + firstChar = text[0] + } + + allWhitespace := true + for { + if util.FirstNonSpacePosition(text) != -1 { + allWhitespace = false + break + } + c = c.NextSibling() + if c == nil { + break + } + segment = c.(*ast.Text).Segment + text = segment.Value(source) + } + if allWhitespace { + return false + } + + var lastChar byte + if len(text) > 0 { + lastChar = text[len(text)-1] + } + + return firstChar == '`' || firstChar == ' ' || lastChar == '`' || lastChar == ' ' +} + +// RenderCodeSpan renders an *ast.CodeSpan node to the given BufWriter. +func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkContinue, nil + } + + code := node.(*ast.CodeSpan) + delimiter := []byte{'`'} + pad := r.shouldPadCodeSpan(source, code) + + if _, err := r.write(w, delimiter); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if pad { + if err := r.writeByte(w, ' '); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + text := c.(*ast.Text).Segment + if _, err := r.write(w, text.Value(source)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + if pad { + if err := r.writeByte(w, ' '); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + if _, err := r.write(w, delimiter); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkSkipChildren, nil +} + +// RenderEmphasis renders an *ast.Emphasis node to the given BufWriter. +func (r *Renderer) renderEmphasis(w util.BufWriter, _ []byte, node ast.Node, _ bool) (ast.WalkStatus, error) { + em := node.(*ast.Emphasis) + if _, err := r.writeString(w, strings.Repeat("*", em.Level)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil +} + +func (r *Renderer) escapeLinkDest(dest []byte) []byte { + requiresEscaping := false + for _, c := range dest { + if c <= 32 || c == '(' || c == ')' || c == 127 { + requiresEscaping = true + break + } + } + if !requiresEscaping { + return dest + } + + escaped := make([]byte, 0, len(dest)+2) + escaped = append(escaped, '<') + for _, c := range dest { + if c == '<' || c == '>' { + escaped = append(escaped, '\\') + } + escaped = append(escaped, c) + } + escaped = append(escaped, '>') + return escaped +} + +func (r *Renderer) linkTitleDelimiter(title []byte) byte { + for i, c := range title { + if c == '"' && (i == 0 || title[i-1] != '\\') { + return '\'' + } + } + return '"' +} + +func (r *Renderer) renderLinkOrImage(w util.BufWriter, open string, dest, title []byte, enter bool) error { + if enter { + if _, err := r.writeString(w, open); err != nil { + return fmt.Errorf(": %w", err) + } + } else { + if _, err := r.writeString(w, "]("); err != nil { + return fmt.Errorf(": %w", err) + } + + if _, err := r.write(w, r.escapeLinkDest(dest)); err != nil { + return fmt.Errorf(": %w", err) + } + if len(title) != 0 { + delimiter := r.linkTitleDelimiter(title) + if _, err := fmt.Fprintf(w, ` %c%s%c`, delimiter, string(title), delimiter); err != nil { + return fmt.Errorf(": %w", err) + } + } + + if err := r.writeByte(w, ')'); err != nil { + return fmt.Errorf(": %w", err) + } + } + return nil +} + +// RenderImage renders an *ast.Image node to the given BufWriter. +func (r *Renderer) renderImage(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + img := node.(*ast.Image) + if err := r.renderLinkOrImage(w, "![", img.Destination, img.Title, enter); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil +} + +// RenderLink renders an *ast.Link node to the given BufWriter. +func (r *Renderer) renderLink(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + link := node.(*ast.Link) + if err := r.renderLinkOrImage(w, "[", link.Destination, link.Title, enter); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil +} + +// RenderRawHTML renders an *ast.RawHTML node to the given BufWriter. +func (r *Renderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkSkipChildren, nil + } + + raw := node.(*ast.RawHTML) + for i := 0; i < raw.Segments.Len(); i++ { + segment := raw.Segments.At(i) + if _, err := r.write(w, segment.Value(source)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkSkipChildren, nil +} + +// RenderText renders an *ast.Text node to the given BufWriter. +func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkContinue, nil + } + + text := node.(*ast.Text) + value := text.Segment.Value(source) + + if _, err := r.write(w, value); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + switch { + case text.HardLineBreak(): + if _, err := r.writeString(w, "\\\n"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + case text.SoftLineBreak(): + if err := r.writeByte(w, '\n'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderString renders an *ast.String node to the given BufWriter. +func (r *Renderer) renderString(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkContinue, nil + } + + str := node.(*ast.String) + if _, err := r.write(w, str.Value); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if _, err := r.writeString(w, "| "); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if _, err := r.writeString(w, " |\n|"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + for x := 0; x < node.ChildCount(); x++ { // use as column count + if _, err := r.writeString(w, " --- |"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if _, err := r.writeString(w, "| "); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if _, err := r.writeString(w, " |"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) renderTableCell(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + if node.NextSibling() != nil { + if _, err := r.writeString(w, " | "); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) renderStrikethrough(w util.BufWriter, _ []byte, _ ast.Node, _ bool) (ast.WalkStatus, error) { + if _, err := r.writeString(w, "~~"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil +} + +func (r *Renderer) renderTaskCheckBox(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + var fill byte = ' ' + if task := node.(*exast.TaskCheckBox); task.IsChecked { + fill = 'x' + } + + if _, err := r.write(w, []byte{'[', fill, ']', ' '}); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} diff --git a/internal/markdown/renderer/markdown/section.go b/internal/markdown/renderer/markdown/section.go new file mode 100644 index 0000000..612ea06 --- /dev/null +++ b/internal/markdown/renderer/markdown/section.go @@ -0,0 +1,35 @@ +package markdown + +import ( + "fmt" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/util" + + "github.com/apricote/releaser-pleaser/internal/markdown/extensions" + rpexast "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast" +) + +func (r *Renderer) renderSection(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + n := node.(*rpexast.Section) + + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if _, err := r.writeString(w, fmt.Sprintf(extensions.SectionStartFormat, n.Name)+"\n"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if _, err := r.writeString(w, "\n"+fmt.Sprintf(extensions.SectionEndFormat, n.Name)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} diff --git a/releaserpleaser.go b/releaserpleaser.go index 6e500f6..54064a4 100644 --- a/releaserpleaser.go +++ b/releaserpleaser.go @@ -239,7 +239,7 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { return err } - changelogEntry, err := changelog.NewChangelogEntry(logger, analyzedCommits, nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix) + changelogEntry, err := changelog.NewChangelogEntry(analyzedCommits, nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix) if err != nil { return fmt.Errorf("failed to build changelog entry: %w", err) }