diff --git a/README.md b/README.md index cb6a627..c283397 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 @@ -43,7 +43,9 @@ coverage: 100.0% of statements ## 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 diff --git a/Render.go b/Render.go index df118b7..024803c 100644 --- a/Render.go +++ b/Render.go @@ -10,8 +10,9 @@ var ( headerEnd = []string{"", "", "", "", "", ""} ) +// renderer represents a Markdown to HTML renderer. type renderer struct { - out strings.Builder + out []byte paragraphLevel int quoteLevel int listLevel int @@ -29,15 +30,17 @@ func Render(markdown string) string { lineStart = 0 ) + r.out = make([]byte, 0, nextPowerOf2(uint32(len(markdown)+4))) + for { if i > len(markdown) { r.closeAll() for range r.quoteLevel { - r.out.WriteString("") + r.WriteString("") } - return r.out.String() + return string(r.out) } if i != len(markdown) && markdown[i] != '\n' { @@ -56,15 +59,15 @@ func Render(markdown string) string { func (r *renderer) processLine(line string) { if r.inCodeBlock { if strings.HasPrefix(line, "```") { - r.out.WriteString("") + r.WriteString("") r.inCodeBlock = false r.codeLines = 0 } else { if r.codeLines != 0 { - r.out.WriteByte('\n') + r.WriteByte('\n') } - r.out.WriteString(html.EscapeString(line)) + r.WriteString(html.EscapeString(line)) r.codeLines++ } @@ -82,13 +85,13 @@ func (r *renderer) processLine(line string) { r.closeParagraphs() for range newQuoteLevel - r.quoteLevel { - r.out.WriteString("
") + r.WriteString("") } } @@ -105,9 +108,9 @@ func (r *renderer) processLine(line string) { space := strings.IndexByte(line, ' ') if space > 0 && space <= 6 { - r.out.WriteString(headerStart[space-1]) + r.WriteString(headerStart[space-1]) r.writeText(line[space+1:]) - r.out.WriteString(headerEnd[space-1]) + r.WriteString(headerEnd[space-1]) } return @@ -117,13 +120,13 @@ func (r *renderer) processLine(line string) { line = strings.TrimSpace(line[1:]) if r.listLevel == 0 { - r.out.WriteString("") } } else if newQuoteLevel < r.quoteLevel { r.closeParagraphs() for range r.quoteLevel - newQuoteLevel { - r.out.WriteString("") + r.WriteString("
")
+ r.WriteString("")
} else {
- r.out.WriteString("")
+ r.WriteString("")
}
r.inCodeBlock = true
@@ -150,7 +153,7 @@ func (r *renderer) processLine(line string) {
line = line[1:]
if r.tableLevel == 0 {
- r.out.WriteString("")
+ r.WriteString("")
r.tableLevel++
}
@@ -160,30 +163,30 @@ func (r *renderer) processLine(line string) {
pipe := strings.IndexByte(line, '|')
if pipe == -1 {
- r.out.WriteString("")
+ r.WriteString("")
return
}
content := strings.TrimSpace(line[:pipe])
if strings.HasPrefix(content, "---") {
- r.out.WriteString("")
+ r.WriteString("")
r.tableHeaderWritten = true
return
}
if column == 0 {
- r.out.WriteString("")
+ r.WriteString(" ")
}
if r.tableHeaderWritten {
- r.out.WriteString("")
+ r.WriteString(" ")
r.writeText(content)
- r.out.WriteString(" ")
+ r.WriteString("")
} else {
- r.out.WriteString("")
+ r.WriteString(" ")
r.writeText(content)
- r.out.WriteString(" ")
+ r.WriteString("")
}
line = line[pipe+1:]
@@ -192,13 +195,13 @@ func (r *renderer) processLine(line string) {
}
if r.paragraphLevel == 0 {
- r.out.WriteString("")
+ r.WriteString("
")
r.paragraphLevel++
r.writeText(line)
return
}
- r.out.WriteByte(' ')
+ r.WriteByte(' ')
r.writeText(line)
}
@@ -212,7 +215,7 @@ func (r *renderer) closeAll() {
// closeParagraphs closes open paragraphs.
func (r *renderer) closeParagraphs() {
for range r.paragraphLevel {
- r.out.WriteString("
")
+ r.WriteString("")
}
r.paragraphLevel = 0
@@ -221,7 +224,7 @@ func (r *renderer) closeParagraphs() {
// closeLists closes open lists.
func (r *renderer) closeLists() {
for range r.listLevel {
- r.out.WriteString("")
+ r.WriteString("")
}
r.listLevel = 0
@@ -230,7 +233,7 @@ func (r *renderer) closeLists() {
// closeTables closes open tables.
func (r *renderer) closeTables() {
for range r.tableLevel {
- r.out.WriteString("
")
+ r.WriteString("
")
}
r.tableLevel = 0
@@ -253,7 +256,7 @@ func (r *renderer) writeText(markdown string) {
for {
if i == len(markdown) {
- r.out.WriteString(html.EscapeString(markdown[tokenStart:]))
+ r.WriteString(html.EscapeString(markdown[tokenStart:]))
return
}
@@ -261,7 +264,7 @@ func (r *renderer) writeText(markdown string) {
switch c {
case '[':
- r.out.WriteString(html.EscapeString(markdown[tokenStart:i]))
+ r.WriteString(html.EscapeString(markdown[tokenStart:i]))
tokenStart = i
textStart = i
case ']':
@@ -279,11 +282,11 @@ func (r *renderer) writeText(markdown string) {
linkText := markdown[textStart+1 : textEnd]
linkURL := markdown[urlStart+1 : i]
- r.out.WriteString("")
- r.out.WriteString(html.EscapeString(linkText))
- r.out.WriteString("")
+ r.WriteString("")
+ r.WriteString(html.EscapeString(linkText))
+ r.WriteString("")
textStart = -1
textEnd = -1
@@ -307,3 +310,27 @@ func sanitizeURL(linkURL string) string {
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
+}
diff --git a/benchmarks_test.go b/benchmarks_test.go
index ffdb413..267fc07 100644
--- a/benchmarks_test.go
+++ b/benchmarks_test.go
@@ -1,13 +1,39 @@
package markdown_test
import (
+ "os"
"testing"
+ "git.akyoto.dev/go/assert"
"git.akyoto.dev/go/markdown"
)
func BenchmarkSmall(b *testing.B) {
+ small, err := os.ReadFile("testdata/small.md")
+ assert.Nil(b, err)
+ input := string(small)
+
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)
}
}
diff --git a/testdata/large.md b/testdata/large.md
new file mode 100644
index 0000000..0f7eaed
--- /dev/null
+++ b/testdata/large.md
@@ -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
\ No newline at end of file
diff --git a/testdata/medium.md b/testdata/medium.md
new file mode 100644
index 0000000..0ccfaa2
--- /dev/null
+++ b/testdata/medium.md
@@ -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.
\ No newline at end of file
diff --git a/testdata/small.md b/testdata/small.md
new file mode 100644
index 0000000..367d81b
--- /dev/null
+++ b/testdata/small.md
@@ -0,0 +1,3 @@
+# Title
+
+Text.
\ No newline at end of file