Improved performance

This commit is contained in:
Eduard Urbach 2024-04-02 10:44:47 +02:00
parent f75ea823a9
commit 09245c5e92
Signed by: akyoto
GPG Key ID: C874F672B1AF20C0
6 changed files with 138 additions and 40 deletions

View File

@ -1,6 +1,6 @@
# markdown # markdown
Markdown renderer. A markdown renderer that supports only a subset of the CommonMark spec in order to make the rendering more efficient and the syntax more consistent.
## Features ## Features
@ -43,7 +43,9 @@ coverage: 100.0% of statements
## Benchmarks ## Benchmarks
``` ```
BenchmarkSmall-12 2421213 494.2 ns/op 248 B/op 5 allocs/op BenchmarkSmall-12 7223979 164.2 ns/op 64 B/op 2 allocs/op
BenchmarkMedium-12 832531 1310 ns/op 992 B/op 2 allocs/op
BenchmarkLarge-12 295946 3732 ns/op 3712 B/op 3 allocs/op
``` ```
## License ## License

101
Render.go
View File

@ -10,8 +10,9 @@ var (
headerEnd = []string{"</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"} headerEnd = []string{"</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"}
) )
// renderer represents a Markdown to HTML renderer.
type renderer struct { type renderer struct {
out strings.Builder out []byte
paragraphLevel int paragraphLevel int
quoteLevel int quoteLevel int
listLevel int listLevel int
@ -29,15 +30,17 @@ func Render(markdown string) string {
lineStart = 0 lineStart = 0
) )
r.out = make([]byte, 0, nextPowerOf2(uint32(len(markdown)+4)))
for { for {
if i > len(markdown) { if i > len(markdown) {
r.closeAll() r.closeAll()
for range r.quoteLevel { for range r.quoteLevel {
r.out.WriteString("</blockquote>") r.WriteString("</blockquote>")
} }
return r.out.String() return string(r.out)
} }
if i != len(markdown) && markdown[i] != '\n' { if i != len(markdown) && markdown[i] != '\n' {
@ -56,15 +59,15 @@ func Render(markdown string) string {
func (r *renderer) processLine(line string) { func (r *renderer) processLine(line string) {
if r.inCodeBlock { if r.inCodeBlock {
if strings.HasPrefix(line, "```") { if strings.HasPrefix(line, "```") {
r.out.WriteString("</code></pre>") r.WriteString("</code></pre>")
r.inCodeBlock = false r.inCodeBlock = false
r.codeLines = 0 r.codeLines = 0
} else { } else {
if r.codeLines != 0 { if r.codeLines != 0 {
r.out.WriteByte('\n') r.WriteByte('\n')
} }
r.out.WriteString(html.EscapeString(line)) r.WriteString(html.EscapeString(line))
r.codeLines++ r.codeLines++
} }
@ -82,13 +85,13 @@ func (r *renderer) processLine(line string) {
r.closeParagraphs() r.closeParagraphs()
for range newQuoteLevel - r.quoteLevel { for range newQuoteLevel - r.quoteLevel {
r.out.WriteString("<blockquote>") r.WriteString("<blockquote>")
} }
} else if newQuoteLevel < r.quoteLevel { } else if newQuoteLevel < r.quoteLevel {
r.closeParagraphs() r.closeParagraphs()
for range r.quoteLevel - newQuoteLevel { for range r.quoteLevel - newQuoteLevel {
r.out.WriteString("</blockquote>") r.WriteString("</blockquote>")
} }
} }
@ -105,9 +108,9 @@ func (r *renderer) processLine(line string) {
space := strings.IndexByte(line, ' ') space := strings.IndexByte(line, ' ')
if space > 0 && space <= 6 { if space > 0 && space <= 6 {
r.out.WriteString(headerStart[space-1]) r.WriteString(headerStart[space-1])
r.writeText(line[space+1:]) r.writeText(line[space+1:])
r.out.WriteString(headerEnd[space-1]) r.WriteString(headerEnd[space-1])
} }
return return
@ -117,13 +120,13 @@ func (r *renderer) processLine(line string) {
line = strings.TrimSpace(line[1:]) line = strings.TrimSpace(line[1:])
if r.listLevel == 0 { if r.listLevel == 0 {
r.out.WriteString("<ul>") r.WriteString("<ul>")
r.listLevel++ r.listLevel++
} }
r.out.WriteString("<li>") r.WriteString("<li>")
r.writeText(line) r.writeText(line)
r.out.WriteString("</li>") r.WriteString("</li>")
return return
case '`': case '`':
@ -132,11 +135,11 @@ func (r *renderer) processLine(line string) {
if !r.inCodeBlock { if !r.inCodeBlock {
if language != "" { if language != "" {
r.out.WriteString("<pre><code class=\"language-") r.WriteString("<pre><code class=\"language-")
r.out.WriteString(html.EscapeString(language)) r.WriteString(html.EscapeString(language))
r.out.WriteString("\">") r.WriteString("\">")
} else { } else {
r.out.WriteString("<pre><code>") r.WriteString("<pre><code>")
} }
r.inCodeBlock = true r.inCodeBlock = true
@ -150,7 +153,7 @@ func (r *renderer) processLine(line string) {
line = line[1:] line = line[1:]
if r.tableLevel == 0 { if r.tableLevel == 0 {
r.out.WriteString("<table><thead>") r.WriteString("<table><thead>")
r.tableLevel++ r.tableLevel++
} }
@ -160,30 +163,30 @@ func (r *renderer) processLine(line string) {
pipe := strings.IndexByte(line, '|') pipe := strings.IndexByte(line, '|')
if pipe == -1 { if pipe == -1 {
r.out.WriteString("</tr>") r.WriteString("</tr>")
return return
} }
content := strings.TrimSpace(line[:pipe]) content := strings.TrimSpace(line[:pipe])
if strings.HasPrefix(content, "---") { if strings.HasPrefix(content, "---") {
r.out.WriteString("</thead><tbody>") r.WriteString("</thead><tbody>")
r.tableHeaderWritten = true r.tableHeaderWritten = true
return return
} }
if column == 0 { if column == 0 {
r.out.WriteString("<tr>") r.WriteString("<tr>")
} }
if r.tableHeaderWritten { if r.tableHeaderWritten {
r.out.WriteString("<td>") r.WriteString("<td>")
r.writeText(content) r.writeText(content)
r.out.WriteString("</td>") r.WriteString("</td>")
} else { } else {
r.out.WriteString("<th>") r.WriteString("<th>")
r.writeText(content) r.writeText(content)
r.out.WriteString("</th>") r.WriteString("</th>")
} }
line = line[pipe+1:] line = line[pipe+1:]
@ -192,13 +195,13 @@ func (r *renderer) processLine(line string) {
} }
if r.paragraphLevel == 0 { if r.paragraphLevel == 0 {
r.out.WriteString("<p>") r.WriteString("<p>")
r.paragraphLevel++ r.paragraphLevel++
r.writeText(line) r.writeText(line)
return return
} }
r.out.WriteByte(' ') r.WriteByte(' ')
r.writeText(line) r.writeText(line)
} }
@ -212,7 +215,7 @@ func (r *renderer) closeAll() {
// closeParagraphs closes open paragraphs. // closeParagraphs closes open paragraphs.
func (r *renderer) closeParagraphs() { func (r *renderer) closeParagraphs() {
for range r.paragraphLevel { for range r.paragraphLevel {
r.out.WriteString("</p>") r.WriteString("</p>")
} }
r.paragraphLevel = 0 r.paragraphLevel = 0
@ -221,7 +224,7 @@ func (r *renderer) closeParagraphs() {
// closeLists closes open lists. // closeLists closes open lists.
func (r *renderer) closeLists() { func (r *renderer) closeLists() {
for range r.listLevel { for range r.listLevel {
r.out.WriteString("</ul>") r.WriteString("</ul>")
} }
r.listLevel = 0 r.listLevel = 0
@ -230,7 +233,7 @@ func (r *renderer) closeLists() {
// closeTables closes open tables. // closeTables closes open tables.
func (r *renderer) closeTables() { func (r *renderer) closeTables() {
for range r.tableLevel { for range r.tableLevel {
r.out.WriteString("</tbody></table>") r.WriteString("</tbody></table>")
} }
r.tableLevel = 0 r.tableLevel = 0
@ -253,7 +256,7 @@ func (r *renderer) writeText(markdown string) {
for { for {
if i == len(markdown) { if i == len(markdown) {
r.out.WriteString(html.EscapeString(markdown[tokenStart:])) r.WriteString(html.EscapeString(markdown[tokenStart:]))
return return
} }
@ -261,7 +264,7 @@ func (r *renderer) writeText(markdown string) {
switch c { switch c {
case '[': case '[':
r.out.WriteString(html.EscapeString(markdown[tokenStart:i])) r.WriteString(html.EscapeString(markdown[tokenStart:i]))
tokenStart = i tokenStart = i
textStart = i textStart = i
case ']': case ']':
@ -279,11 +282,11 @@ func (r *renderer) writeText(markdown string) {
linkText := markdown[textStart+1 : textEnd] linkText := markdown[textStart+1 : textEnd]
linkURL := markdown[urlStart+1 : i] linkURL := markdown[urlStart+1 : i]
r.out.WriteString("<a href=\"") r.WriteString("<a href=\"")
r.out.WriteString(sanitizeURL(linkURL)) r.WriteString(sanitizeURL(linkURL))
r.out.WriteString("\">") r.WriteString("\">")
r.out.WriteString(html.EscapeString(linkText)) r.WriteString(html.EscapeString(linkText))
r.out.WriteString("</a>") r.WriteString("</a>")
textStart = -1 textStart = -1
textEnd = -1 textEnd = -1
@ -307,3 +310,27 @@ func sanitizeURL(linkURL string) string {
return html.EscapeString(linkURL) return html.EscapeString(linkURL)
} }
// WriteByte adds a single byte to the output.
func (r *renderer) WriteByte(b byte) error {
r.out = append(r.out, b)
return nil
}
// WriteString adds a string to the output.
func (r *renderer) WriteString(text string) (int, error) {
r.out = append(r.out, text...)
return len(text), nil
}
// nextPowerOf2 calculates the next 32-bit power of 2.
func nextPowerOf2(x uint32) uint32 {
x--
x |= x >> 1
x |= x >> 2
x |= x >> 4
x |= x >> 8
x |= x >> 16
x++
return x
}

View File

@ -1,13 +1,39 @@
package markdown_test package markdown_test
import ( import (
"os"
"testing" "testing"
"git.akyoto.dev/go/assert"
"git.akyoto.dev/go/markdown" "git.akyoto.dev/go/markdown"
) )
func BenchmarkSmall(b *testing.B) { func BenchmarkSmall(b *testing.B) {
small, err := os.ReadFile("testdata/small.md")
assert.Nil(b, err)
input := string(small)
for range b.N { for range b.N {
markdown.Render("# Header\nText.\nText.\n# Header\nText.\nText.") markdown.Render(input)
}
}
func BenchmarkMedium(b *testing.B) {
medium, err := os.ReadFile("testdata/medium.md")
assert.Nil(b, err)
input := string(medium)
for range b.N {
markdown.Render(input)
}
}
func BenchmarkLarge(b *testing.B) {
small, err := os.ReadFile("testdata/large.md")
assert.Nil(b, err)
input := string(small)
for range b.N {
markdown.Render(input)
} }
} }

37
testdata/large.md vendored Normal file
View File

@ -0,0 +1,37 @@
# Lorem Ipsum
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
# Code
```shell
mkdir example
cd example
touch example.txt
```
# Formatting
*Italic*, **bold**, `monospace` and [links](https://example.com/).
# Lists
- List entry
- List entry
- List entry
# Tables
| X | Y |
| --- | --- |
| 0 | 0 |
| 1 | 2 |
| 2 | 4 |
| 3 | 6 |
| 4 | 8 |
# Quotes
> Blockquote
> Blockquote
> Blockquote

3
testdata/medium.md vendored Normal file
View File

@ -0,0 +1,3 @@
# Lorem Ipsum
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

3
testdata/small.md vendored Normal file
View File

@ -0,0 +1,3 @@
# Title
Text.