Improved performance
This commit is contained in:
parent
f75ea823a9
commit
09245c5e92
@ -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
101
Render.go
@ -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
|
||||||
|
}
|
||||||
|
@ -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
37
testdata/large.md
vendored
Normal 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
3
testdata/medium.md
vendored
Normal 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
3
testdata/small.md
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Title
|
||||||
|
|
||||||
|
Text.
|
Loading…
Reference in New Issue
Block a user