From 0ec909aec645fadd7882a1304dbcdb889f520fa4 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Sun, 31 Mar 2024 21:01:47 +0200 Subject: [PATCH 01/55] Initial commit --- .gitignore | 7 +++++++ README.md | 35 +++++++++++++++++++++++++++++++++++ Render.go | 6 ++++++ Render_test.go | 13 +++++++++++++ go.mod | 5 +++++ go.sum | 2 ++ 6 files changed, 68 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Render.go create mode 100644 Render_test.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26b0a26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +* +!*/ +!.gitignore +!*.go +!*.md +!*.mod +!*.sum \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b395b3f --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# markdown + +Markdown renderer. + +## Features + + + +## Installation + +```shell +go get git.akyoto.dev/go/markdown +``` + +## Usage + +```go +``` + +## Tests + +``` +``` + +## Benchmarks + + + +## License + +Please see the [license documentation](https://akyoto.dev/license). + +## Copyright + +© 2024 Eduard Urbach \ No newline at end of file diff --git a/Render.go b/Render.go new file mode 100644 index 0000000..3197db0 --- /dev/null +++ b/Render.go @@ -0,0 +1,6 @@ +package markdown + +// Render creates HTML from the supplied markdown text. +func Render(markdown string) string { + return "" +} diff --git a/Render_test.go b/Render_test.go new file mode 100644 index 0000000..c2ab965 --- /dev/null +++ b/Render_test.go @@ -0,0 +1,13 @@ +package markdown_test + +import ( + "testing" + + "git.akyoto.dev/go/assert" + "git.akyoto.dev/go/markdown" +) + +func TestRender(t *testing.T) { + html := markdown.Render("# Hello") + assert.Equal(t, html, "

Hello

") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f178769 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.akyoto.dev/go/markdown + +go 1.22.1 + +require git.akyoto.dev/go/assert v0.1.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9fc2547 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8= +git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM= From a4e5243ab359422cb23b7f263e572986bbbded75 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Sun, 31 Mar 2024 21:01:47 +0200 Subject: [PATCH 02/55] Initial commit --- .gitignore | 7 +++++++ README.md | 35 +++++++++++++++++++++++++++++++++++ Render.go | 6 ++++++ Render_test.go | 13 +++++++++++++ go.mod | 5 +++++ go.sum | 2 ++ 6 files changed, 68 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Render.go create mode 100644 Render_test.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26b0a26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +* +!*/ +!.gitignore +!*.go +!*.md +!*.mod +!*.sum \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b395b3f --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# markdown + +Markdown renderer. + +## Features + + + +## Installation + +```shell +go get git.akyoto.dev/go/markdown +``` + +## Usage + +```go +``` + +## Tests + +``` +``` + +## Benchmarks + + + +## License + +Please see the [license documentation](https://akyoto.dev/license). + +## Copyright + +© 2024 Eduard Urbach \ No newline at end of file diff --git a/Render.go b/Render.go new file mode 100644 index 0000000..3197db0 --- /dev/null +++ b/Render.go @@ -0,0 +1,6 @@ +package markdown + +// Render creates HTML from the supplied markdown text. +func Render(markdown string) string { + return "" +} diff --git a/Render_test.go b/Render_test.go new file mode 100644 index 0000000..c2ab965 --- /dev/null +++ b/Render_test.go @@ -0,0 +1,13 @@ +package markdown_test + +import ( + "testing" + + "git.akyoto.dev/go/assert" + "git.akyoto.dev/go/markdown" +) + +func TestRender(t *testing.T) { + html := markdown.Render("# Hello") + assert.Equal(t, html, "

Hello

") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f178769 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.akyoto.dev/go/markdown + +go 1.22.1 + +require git.akyoto.dev/go/assert v0.1.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9fc2547 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8= +git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM= From b0a8dfdc9f56f42be4cabb0101276fcc33da46b0 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Sun, 31 Mar 2024 23:08:01 +0200 Subject: [PATCH 03/55] Implemented headers and paragraphs --- README.md | 14 ++++++++-- Render.go | 67 +++++++++++++++++++++++++++++++++++++++++++++- Render_test.go | 27 ++++++++++++++++--- benchmarks_test.go | 13 +++++++++ 4 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 benchmarks_test.go diff --git a/README.md b/README.md index b395b3f..b3d4f7f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Markdown renderer. ## Features - +- Headers +- Paragraphs ## Installation @@ -15,16 +16,25 @@ go get git.akyoto.dev/go/markdown ## Usage ```go +html := markdown.Render("# Header") +fmt.Println(html) ``` ## Tests ``` +PASS: TestEmpty +PASS: TestParagraphs +PASS: TestHeader +PASS: TestCombined +coverage: 100.0% of statements ``` ## Benchmarks - +``` +BenchmarkSmall-12 2187232 544.9 ns/op 296 B/op 9 allocs/op +``` ## License diff --git a/Render.go b/Render.go index 3197db0..55c3f83 100644 --- a/Render.go +++ b/Render.go @@ -1,6 +1,71 @@ package markdown +import ( + "html" + "strings" +) + +var ( + headerStart = []string{"

", "

", "

", "

", "

", "
"} + headerEnd = []string{"
", "", "", "", "", ""} +) + // Render creates HTML from the supplied markdown text. func Render(markdown string) string { - return "" + var ( + out = strings.Builder{} + paragraph = strings.Builder{} + i = 0 + lineStart = 0 + ) + + flush := func() { + if paragraph.Len() == 0 { + return + } + + out.WriteString("

") + out.WriteString(html.EscapeString(paragraph.String())) + out.WriteString("

") + paragraph.Reset() + } + + for { + if i > len(markdown) { + flush() + return out.String() + } + + if i != len(markdown) && markdown[i] != '\n' { + i++ + continue + } + + line := markdown[lineStart:i] + lineStart = i + 1 + i++ + + switch { + case strings.HasPrefix(line, "#"): + flush() + space := strings.IndexByte(line, ' ') + + if space > 0 && space <= 6 { + out.WriteString(headerStart[space-1]) + out.WriteString(html.EscapeString(line[space+1:])) + out.WriteString(headerEnd[space-1]) + } + + default: + if len(line) == 0 { + continue + } + + if paragraph.Len() > 0 { + paragraph.WriteByte(' ') + } + + paragraph.WriteString(line) + } + } } diff --git a/Render_test.go b/Render_test.go index c2ab965..d20a666 100644 --- a/Render_test.go +++ b/Render_test.go @@ -7,7 +7,28 @@ import ( "git.akyoto.dev/go/markdown" ) -func TestRender(t *testing.T) { - html := markdown.Render("# Hello") - assert.Equal(t, html, "

Hello

") +func TestEmpty(t *testing.T) { + assert.Equal(t, markdown.Render(""), "") +} + +func TestParagraphs(t *testing.T) { + assert.Equal(t, markdown.Render("Text"), "

Text

") + assert.Equal(t, markdown.Render("Text\n"), "

Text

") + assert.Equal(t, markdown.Render("Text\n\n"), "

Text

") + assert.Equal(t, markdown.Render("Text\n\n\n"), "

Text

") + assert.Equal(t, markdown.Render("Line 1\nLine 2"), "

Line 1 Line 2

") +} + +func TestHeader(t *testing.T) { + assert.Equal(t, markdown.Render("# Header"), "

Header

") + assert.Equal(t, markdown.Render("## Header"), "

Header

") + assert.Equal(t, markdown.Render("### Header"), "

Header

") + assert.Equal(t, markdown.Render("#### Header"), "

Header

") + assert.Equal(t, markdown.Render("##### Header"), "
Header
") + assert.Equal(t, markdown.Render("###### Header"), "
Header
") +} + +func TestCombined(t *testing.T) { + assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "

Header

Line 1. Line 2. Line 3.

") + assert.Equal(t, markdown.Render("# Header 1\nLine 1.\n# Header 2\nLine 2."), "

Header 1

Line 1.

Header 2

Line 2.

") } diff --git a/benchmarks_test.go b/benchmarks_test.go new file mode 100644 index 0000000..ffdb413 --- /dev/null +++ b/benchmarks_test.go @@ -0,0 +1,13 @@ +package markdown_test + +import ( + "testing" + + "git.akyoto.dev/go/markdown" +) + +func BenchmarkSmall(b *testing.B) { + for range b.N { + markdown.Render("# Header\nText.\nText.\n# Header\nText.\nText.") + } +} From 93d5949eff42bcac8f4c563d9034e2e4bd6eed31 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Sun, 31 Mar 2024 23:08:01 +0200 Subject: [PATCH 04/55] Implemented headers and paragraphs --- README.md | 14 ++++++++-- Render.go | 67 +++++++++++++++++++++++++++++++++++++++++++++- Render_test.go | 27 ++++++++++++++++--- benchmarks_test.go | 13 +++++++++ 4 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 benchmarks_test.go diff --git a/README.md b/README.md index b395b3f..b3d4f7f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Markdown renderer. ## Features - +- Headers +- Paragraphs ## Installation @@ -15,16 +16,25 @@ go get git.akyoto.dev/go/markdown ## Usage ```go +html := markdown.Render("# Header") +fmt.Println(html) ``` ## Tests ``` +PASS: TestEmpty +PASS: TestParagraphs +PASS: TestHeader +PASS: TestCombined +coverage: 100.0% of statements ``` ## Benchmarks - +``` +BenchmarkSmall-12 2187232 544.9 ns/op 296 B/op 9 allocs/op +``` ## License diff --git a/Render.go b/Render.go index 3197db0..55c3f83 100644 --- a/Render.go +++ b/Render.go @@ -1,6 +1,71 @@ package markdown +import ( + "html" + "strings" +) + +var ( + headerStart = []string{"

", "

", "

", "

", "

", "
"} + headerEnd = []string{"
", "", "", "", "", ""} +) + // Render creates HTML from the supplied markdown text. func Render(markdown string) string { - return "" + var ( + out = strings.Builder{} + paragraph = strings.Builder{} + i = 0 + lineStart = 0 + ) + + flush := func() { + if paragraph.Len() == 0 { + return + } + + out.WriteString("

") + out.WriteString(html.EscapeString(paragraph.String())) + out.WriteString("

") + paragraph.Reset() + } + + for { + if i > len(markdown) { + flush() + return out.String() + } + + if i != len(markdown) && markdown[i] != '\n' { + i++ + continue + } + + line := markdown[lineStart:i] + lineStart = i + 1 + i++ + + switch { + case strings.HasPrefix(line, "#"): + flush() + space := strings.IndexByte(line, ' ') + + if space > 0 && space <= 6 { + out.WriteString(headerStart[space-1]) + out.WriteString(html.EscapeString(line[space+1:])) + out.WriteString(headerEnd[space-1]) + } + + default: + if len(line) == 0 { + continue + } + + if paragraph.Len() > 0 { + paragraph.WriteByte(' ') + } + + paragraph.WriteString(line) + } + } } diff --git a/Render_test.go b/Render_test.go index c2ab965..d20a666 100644 --- a/Render_test.go +++ b/Render_test.go @@ -7,7 +7,28 @@ import ( "git.akyoto.dev/go/markdown" ) -func TestRender(t *testing.T) { - html := markdown.Render("# Hello") - assert.Equal(t, html, "

Hello

") +func TestEmpty(t *testing.T) { + assert.Equal(t, markdown.Render(""), "") +} + +func TestParagraphs(t *testing.T) { + assert.Equal(t, markdown.Render("Text"), "

Text

") + assert.Equal(t, markdown.Render("Text\n"), "

Text

") + assert.Equal(t, markdown.Render("Text\n\n"), "

Text

") + assert.Equal(t, markdown.Render("Text\n\n\n"), "

Text

") + assert.Equal(t, markdown.Render("Line 1\nLine 2"), "

Line 1 Line 2

") +} + +func TestHeader(t *testing.T) { + assert.Equal(t, markdown.Render("# Header"), "

Header

") + assert.Equal(t, markdown.Render("## Header"), "

Header

") + assert.Equal(t, markdown.Render("### Header"), "

Header

") + assert.Equal(t, markdown.Render("#### Header"), "

Header

") + assert.Equal(t, markdown.Render("##### Header"), "
Header
") + assert.Equal(t, markdown.Render("###### Header"), "
Header
") +} + +func TestCombined(t *testing.T) { + assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "

Header

Line 1. Line 2. Line 3.

") + assert.Equal(t, markdown.Render("# Header 1\nLine 1.\n# Header 2\nLine 2."), "

Header 1

Line 1.

Header 2

Line 2.

") } diff --git a/benchmarks_test.go b/benchmarks_test.go new file mode 100644 index 0000000..ffdb413 --- /dev/null +++ b/benchmarks_test.go @@ -0,0 +1,13 @@ +package markdown_test + +import ( + "testing" + + "git.akyoto.dev/go/markdown" +) + +func BenchmarkSmall(b *testing.B) { + for range b.N { + markdown.Render("# Header\nText.\nText.\n# Header\nText.\nText.") + } +} From 412dcbd4a4dfd3e7b7e47f6e654b2b1dacec9731 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 11:54:14 +0200 Subject: [PATCH 05/55] Implemented links --- README.md | 6 +++-- Render.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++++-- Render_test.go | 19 +++++++++++++- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b3d4f7f..762274f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Markdown renderer. ## Features +- Links - Headers - Paragraphs @@ -17,7 +18,6 @@ go get git.akyoto.dev/go/markdown ```go html := markdown.Render("# Header") -fmt.Println(html) ``` ## Tests @@ -26,14 +26,16 @@ fmt.Println(html) PASS: TestEmpty PASS: TestParagraphs PASS: TestHeader +PASS: TestLink PASS: TestCombined +PASS: TestSecurity coverage: 100.0% of statements ``` ## Benchmarks ``` -BenchmarkSmall-12 2187232 544.9 ns/op 296 B/op 9 allocs/op +BenchmarkSmall-12 2019103 591.4 ns/op 296 B/op 9 allocs/op ``` ## License diff --git a/Render.go b/Render.go index 55c3f83..07e23e5 100644 --- a/Render.go +++ b/Render.go @@ -25,7 +25,7 @@ func Render(markdown string) string { } out.WriteString("

") - out.WriteString(html.EscapeString(paragraph.String())) + writeText(&out, paragraph.String()) out.WriteString("

") paragraph.Reset() } @@ -52,7 +52,7 @@ func Render(markdown string) string { if space > 0 && space <= 6 { out.WriteString(headerStart[space-1]) - out.WriteString(html.EscapeString(line[space+1:])) + writeText(&out, line[space+1:]) out.WriteString(headerEnd[space-1]) } @@ -69,3 +69,70 @@ func Render(markdown string) string { } } } + +func writeText(out *strings.Builder, text string) { + var ( + i = 0 + tokenStart = 0 + ) + + var ( + textStart = -1 + textEnd = -1 + urlStart = -1 + parentheses = 0 + ) + + for { + if i == len(text) { + out.WriteString(html.EscapeString(text[tokenStart:])) + return + } + + c := text[i] + + switch c { + case '[': + out.WriteString(html.EscapeString(text[tokenStart:i])) + tokenStart = i + textStart = i + case ']': + textEnd = i + case '(': + if parentheses == 0 { + urlStart = i + } + + parentheses++ + case ')': + parentheses-- + + if parentheses == 0 && textStart >= 0 && textEnd >= 0 && urlStart >= 0 { + linkText := text[textStart+1 : textEnd] + linkURL := text[urlStart+1 : i] + + out.WriteString("") + out.WriteString(html.EscapeString(linkText)) + out.WriteString("") + + textStart = -1 + textEnd = -1 + urlStart = -1 + + tokenStart = i + 1 + } + } + + i++ + } +} + +func formatURL(linkURL string) string { + if strings.HasPrefix(strings.ToLower(linkURL), "javascript:") { + return "" + } + + return html.EscapeString(linkURL) +} diff --git a/Render_test.go b/Render_test.go index d20a666..74108d1 100644 --- a/Render_test.go +++ b/Render_test.go @@ -11,7 +11,7 @@ func TestEmpty(t *testing.T) { assert.Equal(t, markdown.Render(""), "") } -func TestParagraphs(t *testing.T) { +func TestParagraph(t *testing.T) { assert.Equal(t, markdown.Render("Text"), "

Text

") assert.Equal(t, markdown.Render("Text\n"), "

Text

") assert.Equal(t, markdown.Render("Text\n\n"), "

Text

") @@ -28,7 +28,24 @@ func TestHeader(t *testing.T) { assert.Equal(t, markdown.Render("###### Header"), "
Header
") } +func TestLink(t *testing.T) { + assert.Equal(t, markdown.Render("[text](https://example.com/)"), "

text

") + assert.Equal(t, markdown.Render("[text](https://example.com/"), "

[text](https://example.com/

") + assert.Equal(t, markdown.Render("[text]https://example.com/)"), "

[text]https://example.com/)

") + assert.Equal(t, markdown.Render("[text(https://example.com/)"), "

[text(https://example.com/)

") + assert.Equal(t, markdown.Render("text](https://example.com/)"), "

text](https://example.com/)

") + assert.Equal(t, markdown.Render("Prefix [text](https://example.com/) suffix."), "

Prefix text suffix.

") +} + func TestCombined(t *testing.T) { assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "

Header

Line 1. Line 2. Line 3.

") assert.Equal(t, markdown.Render("# Header 1\nLine 1.\n# Header 2\nLine 2."), "

Header 1

Line 1.

Header 2

Line 2.

") + assert.Equal(t, markdown.Render("# [Header Link](https://example.com/)"), "

Header Link

") +} + +func TestSecurity(t *testing.T) { + assert.Equal(t, markdown.Render("[text](javascript:alert(\"xss\"))"), "

text

") + assert.Equal(t, markdown.Render("[text](javAscRipt:alert(\"xss\"))"), "

text

") + assert.Equal(t, markdown.Render("[text](\">
html
)"), "

text

") + assert.Equal(t, markdown.Render("[
html
]()"), "

<div>html</div>

") } From 862f9837e46f7ccb6523979cdc4012931c946663 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 11:54:14 +0200 Subject: [PATCH 06/55] Implemented links --- README.md | 6 +++-- Render.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++++-- Render_test.go | 19 +++++++++++++- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b3d4f7f..762274f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Markdown renderer. ## Features +- Links - Headers - Paragraphs @@ -17,7 +18,6 @@ go get git.akyoto.dev/go/markdown ```go html := markdown.Render("# Header") -fmt.Println(html) ``` ## Tests @@ -26,14 +26,16 @@ fmt.Println(html) PASS: TestEmpty PASS: TestParagraphs PASS: TestHeader +PASS: TestLink PASS: TestCombined +PASS: TestSecurity coverage: 100.0% of statements ``` ## Benchmarks ``` -BenchmarkSmall-12 2187232 544.9 ns/op 296 B/op 9 allocs/op +BenchmarkSmall-12 2019103 591.4 ns/op 296 B/op 9 allocs/op ``` ## License diff --git a/Render.go b/Render.go index 55c3f83..07e23e5 100644 --- a/Render.go +++ b/Render.go @@ -25,7 +25,7 @@ func Render(markdown string) string { } out.WriteString("

") - out.WriteString(html.EscapeString(paragraph.String())) + writeText(&out, paragraph.String()) out.WriteString("

") paragraph.Reset() } @@ -52,7 +52,7 @@ func Render(markdown string) string { if space > 0 && space <= 6 { out.WriteString(headerStart[space-1]) - out.WriteString(html.EscapeString(line[space+1:])) + writeText(&out, line[space+1:]) out.WriteString(headerEnd[space-1]) } @@ -69,3 +69,70 @@ func Render(markdown string) string { } } } + +func writeText(out *strings.Builder, text string) { + var ( + i = 0 + tokenStart = 0 + ) + + var ( + textStart = -1 + textEnd = -1 + urlStart = -1 + parentheses = 0 + ) + + for { + if i == len(text) { + out.WriteString(html.EscapeString(text[tokenStart:])) + return + } + + c := text[i] + + switch c { + case '[': + out.WriteString(html.EscapeString(text[tokenStart:i])) + tokenStart = i + textStart = i + case ']': + textEnd = i + case '(': + if parentheses == 0 { + urlStart = i + } + + parentheses++ + case ')': + parentheses-- + + if parentheses == 0 && textStart >= 0 && textEnd >= 0 && urlStart >= 0 { + linkText := text[textStart+1 : textEnd] + linkURL := text[urlStart+1 : i] + + out.WriteString("") + out.WriteString(html.EscapeString(linkText)) + out.WriteString("") + + textStart = -1 + textEnd = -1 + urlStart = -1 + + tokenStart = i + 1 + } + } + + i++ + } +} + +func formatURL(linkURL string) string { + if strings.HasPrefix(strings.ToLower(linkURL), "javascript:") { + return "" + } + + return html.EscapeString(linkURL) +} diff --git a/Render_test.go b/Render_test.go index d20a666..74108d1 100644 --- a/Render_test.go +++ b/Render_test.go @@ -11,7 +11,7 @@ func TestEmpty(t *testing.T) { assert.Equal(t, markdown.Render(""), "") } -func TestParagraphs(t *testing.T) { +func TestParagraph(t *testing.T) { assert.Equal(t, markdown.Render("Text"), "

Text

") assert.Equal(t, markdown.Render("Text\n"), "

Text

") assert.Equal(t, markdown.Render("Text\n\n"), "

Text

") @@ -28,7 +28,24 @@ func TestHeader(t *testing.T) { assert.Equal(t, markdown.Render("###### Header"), "
Header
") } +func TestLink(t *testing.T) { + assert.Equal(t, markdown.Render("[text](https://example.com/)"), "

text

") + assert.Equal(t, markdown.Render("[text](https://example.com/"), "

[text](https://example.com/

") + assert.Equal(t, markdown.Render("[text]https://example.com/)"), "

[text]https://example.com/)

") + assert.Equal(t, markdown.Render("[text(https://example.com/)"), "

[text(https://example.com/)

") + assert.Equal(t, markdown.Render("text](https://example.com/)"), "

text](https://example.com/)

") + assert.Equal(t, markdown.Render("Prefix [text](https://example.com/) suffix."), "

Prefix text suffix.

") +} + func TestCombined(t *testing.T) { assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "

Header

Line 1. Line 2. Line 3.

") assert.Equal(t, markdown.Render("# Header 1\nLine 1.\n# Header 2\nLine 2."), "

Header 1

Line 1.

Header 2

Line 2.

") + assert.Equal(t, markdown.Render("# [Header Link](https://example.com/)"), "

Header Link

") +} + +func TestSecurity(t *testing.T) { + assert.Equal(t, markdown.Render("[text](javascript:alert(\"xss\"))"), "

text

") + assert.Equal(t, markdown.Render("[text](javAscRipt:alert(\"xss\"))"), "

text

") + assert.Equal(t, markdown.Render("[text](\">
html
)"), "

text

") + assert.Equal(t, markdown.Render("[
html
]()"), "

<div>html</div>

") } From 39e98167a24a6972e1159c65b52f4bbad1cb1129 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 12:43:36 +0200 Subject: [PATCH 07/55] Added more tests --- Render.go | 1 + Render_test.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Render.go b/Render.go index 07e23e5..60570a8 100644 --- a/Render.go +++ b/Render.go @@ -58,6 +58,7 @@ func Render(markdown string) string { default: if len(line) == 0 { + flush() continue } diff --git a/Render_test.go b/Render_test.go index 74108d1..a50f3af 100644 --- a/Render_test.go +++ b/Render_test.go @@ -17,6 +17,7 @@ func TestParagraph(t *testing.T) { assert.Equal(t, markdown.Render("Text\n\n"), "

Text

") assert.Equal(t, markdown.Render("Text\n\n\n"), "

Text

") assert.Equal(t, markdown.Render("Line 1\nLine 2"), "

Line 1 Line 2

") + assert.Equal(t, markdown.Render("Line 1\n\nLine 2"), "

Line 1

Line 2

") } func TestHeader(t *testing.T) { @@ -38,6 +39,7 @@ func TestLink(t *testing.T) { } func TestCombined(t *testing.T) { + assert.Equal(t, markdown.Render("# Header\n\nLine 1."), "

Header

Line 1.

") assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "

Header

Line 1. Line 2. Line 3.

") assert.Equal(t, markdown.Render("# Header 1\nLine 1.\n# Header 2\nLine 2."), "

Header 1

Line 1.

Header 2

Line 2.

") assert.Equal(t, markdown.Render("# [Header Link](https://example.com/)"), "

Header Link

") From 302fd393c8b95e296d4bc0281e0e2e0ff6055aad Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 12:43:36 +0200 Subject: [PATCH 08/55] Added more tests --- Render.go | 1 + Render_test.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Render.go b/Render.go index 07e23e5..60570a8 100644 --- a/Render.go +++ b/Render.go @@ -58,6 +58,7 @@ func Render(markdown string) string { default: if len(line) == 0 { + flush() continue } diff --git a/Render_test.go b/Render_test.go index 74108d1..a50f3af 100644 --- a/Render_test.go +++ b/Render_test.go @@ -17,6 +17,7 @@ func TestParagraph(t *testing.T) { assert.Equal(t, markdown.Render("Text\n\n"), "

Text

") assert.Equal(t, markdown.Render("Text\n\n\n"), "

Text

") assert.Equal(t, markdown.Render("Line 1\nLine 2"), "

Line 1 Line 2

") + assert.Equal(t, markdown.Render("Line 1\n\nLine 2"), "

Line 1

Line 2

") } func TestHeader(t *testing.T) { @@ -38,6 +39,7 @@ func TestLink(t *testing.T) { } func TestCombined(t *testing.T) { + assert.Equal(t, markdown.Render("# Header\n\nLine 1."), "

Header

Line 1.

") assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "

Header

Line 1. Line 2. Line 3.

") assert.Equal(t, markdown.Render("# Header 1\nLine 1.\n# Header 2\nLine 2."), "

Header 1

Line 1.

Header 2

Line 2.

") assert.Equal(t, markdown.Render("# [Header Link](https://example.com/)"), "

Header Link

") From 7abc987c35662886037d3f69ab4dc0ca46b23a22 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 18:10:52 +0200 Subject: [PATCH 09/55] Implemented block quotes --- README.md | 4 +- Render.go | 142 +++++++++++++++++++++++++++++++------------------ Render_test.go | 8 +++ 3 files changed, 102 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 762274f..b59879e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Markdown renderer. - Links - Headers - Paragraphs +- Quotes ## Installation @@ -27,6 +28,7 @@ PASS: TestEmpty PASS: TestParagraphs PASS: TestHeader PASS: TestLink +PASS: TestQuote PASS: TestCombined PASS: TestSecurity coverage: 100.0% of statements @@ -35,7 +37,7 @@ coverage: 100.0% of statements ## Benchmarks ``` -BenchmarkSmall-12 2019103 591.4 ns/op 296 B/op 9 allocs/op +BenchmarkSmall-12 2425053 492.8 ns/op 248 B/op 5 allocs/op ``` ## License diff --git a/Render.go b/Render.go index 60570a8..8c1fa2b 100644 --- a/Render.go +++ b/Render.go @@ -10,30 +10,29 @@ var ( headerEnd = []string{"", "", "", "", "", ""} ) +type renderer struct { + out strings.Builder + paragraphLevel int + quoteLevel int +} + // Render creates HTML from the supplied markdown text. func Render(markdown string) string { var ( - out = strings.Builder{} - paragraph = strings.Builder{} + r renderer i = 0 lineStart = 0 ) - flush := func() { - if paragraph.Len() == 0 { - return - } - - out.WriteString("

") - writeText(&out, paragraph.String()) - out.WriteString("

") - paragraph.Reset() - } - for { if i > len(markdown) { - flush() - return out.String() + r.closeParagraphs() + + for range r.quoteLevel { + r.out.WriteString("") + } + + return r.out.String() } if i != len(markdown) && markdown[i] != '\n' { @@ -45,33 +44,74 @@ func Render(markdown string) string { lineStart = i + 1 i++ - switch { - case strings.HasPrefix(line, "#"): - flush() - space := strings.IndexByte(line, ' ') - - if space > 0 && space <= 6 { - out.WriteString(headerStart[space-1]) - writeText(&out, line[space+1:]) - out.WriteString(headerEnd[space-1]) - } - - default: - if len(line) == 0 { - flush() - continue - } - - if paragraph.Len() > 0 { - paragraph.WriteByte(' ') - } - - paragraph.WriteString(line) - } + r.processLine(line) } } -func writeText(out *strings.Builder, text string) { +func (r *renderer) processLine(line string) { + newQuoteLevel := 0 + + for strings.HasPrefix(line, ">") { + line = strings.TrimSpace(line[1:]) + newQuoteLevel++ + } + + if newQuoteLevel > r.quoteLevel { + r.closeParagraphs() + + for range newQuoteLevel - r.quoteLevel { + r.out.WriteString("
") + } + } else if newQuoteLevel < r.quoteLevel { + r.closeParagraphs() + + for range r.quoteLevel - newQuoteLevel { + r.out.WriteString("
") + } + } + + r.quoteLevel = newQuoteLevel + + if strings.HasPrefix(line, "#") { + r.closeParagraphs() + space := strings.IndexByte(line, ' ') + + if space > 0 && space <= 6 { + r.out.WriteString(headerStart[space-1]) + r.writeText(line[space+1:]) + r.out.WriteString(headerEnd[space-1]) + } + + return + } + + if len(line) == 0 { + r.closeParagraphs() + return + } + + if r.paragraphLevel == 0 { + r.out.WriteString("

") + r.paragraphLevel++ + r.writeText(line) + return + } + + r.out.WriteByte(' ') + r.writeText(line) +} + +// closeParagraphs closes open paragraphs. +func (r *renderer) closeParagraphs() { + for range r.paragraphLevel { + r.out.WriteString("

") + } + + r.paragraphLevel = 0 +} + +// writeText converts inline markdown to HTML. +func (r *renderer) writeText(markdown string) { var ( i = 0 tokenStart = 0 @@ -85,16 +125,16 @@ func writeText(out *strings.Builder, text string) { ) for { - if i == len(text) { - out.WriteString(html.EscapeString(text[tokenStart:])) + if i == len(markdown) { + r.out.WriteString(html.EscapeString(markdown[tokenStart:])) return } - c := text[i] + c := markdown[i] switch c { case '[': - out.WriteString(html.EscapeString(text[tokenStart:i])) + r.out.WriteString(html.EscapeString(markdown[tokenStart:i])) tokenStart = i textStart = i case ']': @@ -109,14 +149,14 @@ func writeText(out *strings.Builder, text string) { parentheses-- if parentheses == 0 && textStart >= 0 && textEnd >= 0 && urlStart >= 0 { - linkText := text[textStart+1 : textEnd] - linkURL := text[urlStart+1 : i] + linkText := markdown[textStart+1 : textEnd] + linkURL := markdown[urlStart+1 : i] - out.WriteString("") - out.WriteString(html.EscapeString(linkText)) - out.WriteString("") + r.out.WriteString("") + r.out.WriteString(html.EscapeString(linkText)) + r.out.WriteString("") textStart = -1 textEnd = -1 @@ -130,7 +170,7 @@ func writeText(out *strings.Builder, text string) { } } -func formatURL(linkURL string) string { +func sanitizeURL(linkURL string) string { if strings.HasPrefix(strings.ToLower(linkURL), "javascript:") { return "" } diff --git a/Render_test.go b/Render_test.go index a50f3af..e47ce23 100644 --- a/Render_test.go +++ b/Render_test.go @@ -38,6 +38,14 @@ func TestLink(t *testing.T) { assert.Equal(t, markdown.Render("Prefix [text](https://example.com/) suffix."), "

Prefix text suffix.

") } +func TestQuote(t *testing.T) { + assert.Equal(t, markdown.Render("> Line"), "

Line

") + assert.Equal(t, markdown.Render("> Line 1\n> Line 2"), "

Line 1 Line 2

") + assert.Equal(t, markdown.Render("> Line 1\n\nLine 2"), "

Line 1

Line 2

") + assert.Equal(t, markdown.Render("> Line 1\n>>Line 2"), "

Line 1

Line 2

") + assert.Equal(t, markdown.Render("Line 1\n> Line 2\n> Line 3\nLine 4"), "

Line 1

Line 2 Line 3

Line 4

") +} + func TestCombined(t *testing.T) { assert.Equal(t, markdown.Render("# Header\n\nLine 1."), "

Header

Line 1.

") assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "

Header

Line 1. Line 2. Line 3.

") From abb1b0c3fc70646f5c26258cee239f7cc4ea06e1 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 18:10:52 +0200 Subject: [PATCH 10/55] Implemented block quotes --- README.md | 4 +- Render.go | 142 +++++++++++++++++++++++++++++++------------------ Render_test.go | 8 +++ 3 files changed, 102 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 762274f..b59879e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Markdown renderer. - Links - Headers - Paragraphs +- Quotes ## Installation @@ -27,6 +28,7 @@ PASS: TestEmpty PASS: TestParagraphs PASS: TestHeader PASS: TestLink +PASS: TestQuote PASS: TestCombined PASS: TestSecurity coverage: 100.0% of statements @@ -35,7 +37,7 @@ coverage: 100.0% of statements ## Benchmarks ``` -BenchmarkSmall-12 2019103 591.4 ns/op 296 B/op 9 allocs/op +BenchmarkSmall-12 2425053 492.8 ns/op 248 B/op 5 allocs/op ``` ## License diff --git a/Render.go b/Render.go index 60570a8..8c1fa2b 100644 --- a/Render.go +++ b/Render.go @@ -10,30 +10,29 @@ var ( headerEnd = []string{"", "", "", "", "", ""} ) +type renderer struct { + out strings.Builder + paragraphLevel int + quoteLevel int +} + // Render creates HTML from the supplied markdown text. func Render(markdown string) string { var ( - out = strings.Builder{} - paragraph = strings.Builder{} + r renderer i = 0 lineStart = 0 ) - flush := func() { - if paragraph.Len() == 0 { - return - } - - out.WriteString("

") - writeText(&out, paragraph.String()) - out.WriteString("

") - paragraph.Reset() - } - for { if i > len(markdown) { - flush() - return out.String() + r.closeParagraphs() + + for range r.quoteLevel { + r.out.WriteString("") + } + + return r.out.String() } if i != len(markdown) && markdown[i] != '\n' { @@ -45,33 +44,74 @@ func Render(markdown string) string { lineStart = i + 1 i++ - switch { - case strings.HasPrefix(line, "#"): - flush() - space := strings.IndexByte(line, ' ') - - if space > 0 && space <= 6 { - out.WriteString(headerStart[space-1]) - writeText(&out, line[space+1:]) - out.WriteString(headerEnd[space-1]) - } - - default: - if len(line) == 0 { - flush() - continue - } - - if paragraph.Len() > 0 { - paragraph.WriteByte(' ') - } - - paragraph.WriteString(line) - } + r.processLine(line) } } -func writeText(out *strings.Builder, text string) { +func (r *renderer) processLine(line string) { + newQuoteLevel := 0 + + for strings.HasPrefix(line, ">") { + line = strings.TrimSpace(line[1:]) + newQuoteLevel++ + } + + if newQuoteLevel > r.quoteLevel { + r.closeParagraphs() + + for range newQuoteLevel - r.quoteLevel { + r.out.WriteString("
") + } + } else if newQuoteLevel < r.quoteLevel { + r.closeParagraphs() + + for range r.quoteLevel - newQuoteLevel { + r.out.WriteString("
") + } + } + + r.quoteLevel = newQuoteLevel + + if strings.HasPrefix(line, "#") { + r.closeParagraphs() + space := strings.IndexByte(line, ' ') + + if space > 0 && space <= 6 { + r.out.WriteString(headerStart[space-1]) + r.writeText(line[space+1:]) + r.out.WriteString(headerEnd[space-1]) + } + + return + } + + if len(line) == 0 { + r.closeParagraphs() + return + } + + if r.paragraphLevel == 0 { + r.out.WriteString("

") + r.paragraphLevel++ + r.writeText(line) + return + } + + r.out.WriteByte(' ') + r.writeText(line) +} + +// closeParagraphs closes open paragraphs. +func (r *renderer) closeParagraphs() { + for range r.paragraphLevel { + r.out.WriteString("

") + } + + r.paragraphLevel = 0 +} + +// writeText converts inline markdown to HTML. +func (r *renderer) writeText(markdown string) { var ( i = 0 tokenStart = 0 @@ -85,16 +125,16 @@ func writeText(out *strings.Builder, text string) { ) for { - if i == len(text) { - out.WriteString(html.EscapeString(text[tokenStart:])) + if i == len(markdown) { + r.out.WriteString(html.EscapeString(markdown[tokenStart:])) return } - c := text[i] + c := markdown[i] switch c { case '[': - out.WriteString(html.EscapeString(text[tokenStart:i])) + r.out.WriteString(html.EscapeString(markdown[tokenStart:i])) tokenStart = i textStart = i case ']': @@ -109,14 +149,14 @@ func writeText(out *strings.Builder, text string) { parentheses-- if parentheses == 0 && textStart >= 0 && textEnd >= 0 && urlStart >= 0 { - linkText := text[textStart+1 : textEnd] - linkURL := text[urlStart+1 : i] + linkText := markdown[textStart+1 : textEnd] + linkURL := markdown[urlStart+1 : i] - out.WriteString("") - out.WriteString(html.EscapeString(linkText)) - out.WriteString("") + r.out.WriteString("") + r.out.WriteString(html.EscapeString(linkText)) + r.out.WriteString("") textStart = -1 textEnd = -1 @@ -130,7 +170,7 @@ func writeText(out *strings.Builder, text string) { } } -func formatURL(linkURL string) string { +func sanitizeURL(linkURL string) string { if strings.HasPrefix(strings.ToLower(linkURL), "javascript:") { return "" } diff --git a/Render_test.go b/Render_test.go index a50f3af..e47ce23 100644 --- a/Render_test.go +++ b/Render_test.go @@ -38,6 +38,14 @@ func TestLink(t *testing.T) { assert.Equal(t, markdown.Render("Prefix [text](https://example.com/) suffix."), "

Prefix text suffix.

") } +func TestQuote(t *testing.T) { + assert.Equal(t, markdown.Render("> Line"), "

Line

") + assert.Equal(t, markdown.Render("> Line 1\n> Line 2"), "

Line 1 Line 2

") + assert.Equal(t, markdown.Render("> Line 1\n\nLine 2"), "

Line 1

Line 2

") + assert.Equal(t, markdown.Render("> Line 1\n>>Line 2"), "

Line 1

Line 2

") + assert.Equal(t, markdown.Render("Line 1\n> Line 2\n> Line 3\nLine 4"), "

Line 1

Line 2 Line 3

Line 4

") +} + func TestCombined(t *testing.T) { assert.Equal(t, markdown.Render("# Header\n\nLine 1."), "

Header

Line 1.

") assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "

Header

Line 1. Line 2. Line 3.

") From 79c67e9f6904a91f4e44b325df232c19f9ebec96 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 19:34:02 +0200 Subject: [PATCH 11/55] Improved URL sanitizer --- Render.go | 2 ++ Render_test.go | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Render.go b/Render.go index 8c1fa2b..1447a87 100644 --- a/Render.go +++ b/Render.go @@ -171,6 +171,8 @@ func (r *renderer) writeText(markdown string) { } func sanitizeURL(linkURL string) string { + linkURL = strings.TrimSpace(linkURL) + if strings.HasPrefix(strings.ToLower(linkURL), "javascript:") { return "" } diff --git a/Render_test.go b/Render_test.go index e47ce23..4ccecaa 100644 --- a/Render_test.go +++ b/Render_test.go @@ -56,6 +56,8 @@ func TestCombined(t *testing.T) { func TestSecurity(t *testing.T) { assert.Equal(t, markdown.Render("[text](javascript:alert(\"xss\"))"), "

text

") assert.Equal(t, markdown.Render("[text](javAscRipt:alert(\"xss\"))"), "

text

") - assert.Equal(t, markdown.Render("[text](\">
html
)"), "

text

") - assert.Equal(t, markdown.Render("[
html
]()"), "

<div>html</div>

") + assert.Equal(t, markdown.Render("[text]( javascript:alert(\"xss\"))"), "

text

") + assert.Equal(t, markdown.Render("[text]('javAscRipt:alert(\"xss\")')"), "

text

") + assert.Equal(t, markdown.Render("[text](\">)"), "

text

") + assert.Equal(t, markdown.Render("[]()"), "

<script>alert(123)</script>

") } From 133d01bd11151d7e80bbc1dc5d5f2b728dc8cdf8 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 19:34:02 +0200 Subject: [PATCH 12/55] Improved URL sanitizer --- Render.go | 2 ++ Render_test.go | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Render.go b/Render.go index 8c1fa2b..1447a87 100644 --- a/Render.go +++ b/Render.go @@ -171,6 +171,8 @@ func (r *renderer) writeText(markdown string) { } func sanitizeURL(linkURL string) string { + linkURL = strings.TrimSpace(linkURL) + if strings.HasPrefix(strings.ToLower(linkURL), "javascript:") { return "" } diff --git a/Render_test.go b/Render_test.go index e47ce23..4ccecaa 100644 --- a/Render_test.go +++ b/Render_test.go @@ -56,6 +56,8 @@ func TestCombined(t *testing.T) { func TestSecurity(t *testing.T) { assert.Equal(t, markdown.Render("[text](javascript:alert(\"xss\"))"), "

text

") assert.Equal(t, markdown.Render("[text](javAscRipt:alert(\"xss\"))"), "

text

") - assert.Equal(t, markdown.Render("[text](\">
html
)"), "

text

") - assert.Equal(t, markdown.Render("[
html
]()"), "

<div>html</div>

") + assert.Equal(t, markdown.Render("[text]( javascript:alert(\"xss\"))"), "

text

") + assert.Equal(t, markdown.Render("[text]('javAscRipt:alert(\"xss\")')"), "

text

") + assert.Equal(t, markdown.Render("[text](\">)"), "

text

") + assert.Equal(t, markdown.Render("[]()"), "

<script>alert(123)</script>

") } From 6ac2147499d7417bf0593dd0f876c29b90e539cc Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 20:04:20 +0200 Subject: [PATCH 13/55] Implemented lists --- README.md | 3 ++- Render.go | 42 +++++++++++++++++++++++++++++++++++++----- Render_test.go | 9 +++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b59879e..c5f42be 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Markdown renderer. ## Features - Links +- Lists - Headers - Paragraphs - Quotes @@ -37,7 +38,7 @@ coverage: 100.0% of statements ## Benchmarks ``` -BenchmarkSmall-12 2425053 492.8 ns/op 248 B/op 5 allocs/op +BenchmarkSmall-12 2585194 462.4 ns/op 248 B/op 5 allocs/op ``` ## License diff --git a/Render.go b/Render.go index 1447a87..d13efe2 100644 --- a/Render.go +++ b/Render.go @@ -14,6 +14,7 @@ type renderer struct { out strings.Builder paragraphLevel int quoteLevel int + listLevel int } // Render creates HTML from the supplied markdown text. @@ -26,7 +27,7 @@ func Render(markdown string) string { for { if i > len(markdown) { - r.closeParagraphs() + r.closeAll() for range r.quoteLevel { r.out.WriteString("") @@ -72,8 +73,14 @@ func (r *renderer) processLine(line string) { r.quoteLevel = newQuoteLevel - if strings.HasPrefix(line, "#") { - r.closeParagraphs() + if len(line) == 0 { + r.closeAll() + return + } + + switch line[0] { + case '#': + r.closeAll() space := strings.IndexByte(line, ' ') if space > 0 && space <= 6 { @@ -83,10 +90,19 @@ func (r *renderer) processLine(line string) { } return - } - if len(line) == 0 { + case '-', '*': r.closeParagraphs() + line = strings.TrimSpace(line[1:]) + + if r.listLevel == 0 { + r.out.WriteString("
    ") + r.listLevel++ + } + + r.out.WriteString("
  • ") + r.writeText(line) + r.out.WriteString("
  • ") return } @@ -101,6 +117,12 @@ func (r *renderer) processLine(line string) { r.writeText(line) } +// closeAll closes all open tags. +func (r *renderer) closeAll() { + r.closeLists() + r.closeParagraphs() +} + // closeParagraphs closes open paragraphs. func (r *renderer) closeParagraphs() { for range r.paragraphLevel { @@ -110,6 +132,15 @@ func (r *renderer) closeParagraphs() { r.paragraphLevel = 0 } +// closeLists closes open lists. +func (r *renderer) closeLists() { + for range r.listLevel { + r.out.WriteString("
") + } + + r.listLevel = 0 +} + // writeText converts inline markdown to HTML. func (r *renderer) writeText(markdown string) { var ( @@ -170,6 +201,7 @@ func (r *renderer) writeText(markdown string) { } } +// sanitizeURL makes a URL safe to use as the value for a `href` attribute. func sanitizeURL(linkURL string) string { linkURL = strings.TrimSpace(linkURL) diff --git a/Render_test.go b/Render_test.go index 4ccecaa..95be167 100644 --- a/Render_test.go +++ b/Render_test.go @@ -38,6 +38,12 @@ func TestLink(t *testing.T) { assert.Equal(t, markdown.Render("Prefix [text](https://example.com/) suffix."), "

Prefix text suffix.

") } +func TestList(t *testing.T) { + assert.Equal(t, markdown.Render("- Entry"), "
  • Entry
") + assert.Equal(t, markdown.Render("- Entry 1\n- Entry 2"), "
  • Entry 1
  • Entry 2
") + assert.Equal(t, markdown.Render("- Entry 1\n- Entry 2\n- Entry 3"), "
  • Entry 1
  • Entry 2
  • Entry 3
") +} + func TestQuote(t *testing.T) { assert.Equal(t, markdown.Render("> Line"), "

Line

") assert.Equal(t, markdown.Render("> Line 1\n> Line 2"), "

Line 1 Line 2

") @@ -51,6 +57,9 @@ func TestCombined(t *testing.T) { assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "

Header

Line 1. Line 2. Line 3.

") assert.Equal(t, markdown.Render("# Header 1\nLine 1.\n# Header 2\nLine 2."), "

Header 1

Line 1.

Header 2

Line 2.

") assert.Equal(t, markdown.Render("# [Header Link](https://example.com/)"), "

Header Link

") + assert.Equal(t, markdown.Render("# Title\n\n- Entry 1\n- Entry 2\n\nText."), "

Title

  • Entry 1
  • Entry 2

Text.

") + assert.Equal(t, markdown.Render("- Entry\n# Header"), "
  • Entry

Header

") + assert.Equal(t, markdown.Render("> - Entry\n> # Header"), "
  • Entry

Header

") } func TestSecurity(t *testing.T) { From 8e009702c2805f5e99de63c36909e7d81b58088e Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 20:04:20 +0200 Subject: [PATCH 14/55] Implemented lists --- README.md | 3 ++- Render.go | 42 +++++++++++++++++++++++++++++++++++++----- Render_test.go | 9 +++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b59879e..c5f42be 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Markdown renderer. ## Features - Links +- Lists - Headers - Paragraphs - Quotes @@ -37,7 +38,7 @@ coverage: 100.0% of statements ## Benchmarks ``` -BenchmarkSmall-12 2425053 492.8 ns/op 248 B/op 5 allocs/op +BenchmarkSmall-12 2585194 462.4 ns/op 248 B/op 5 allocs/op ``` ## License diff --git a/Render.go b/Render.go index 1447a87..d13efe2 100644 --- a/Render.go +++ b/Render.go @@ -14,6 +14,7 @@ type renderer struct { out strings.Builder paragraphLevel int quoteLevel int + listLevel int } // Render creates HTML from the supplied markdown text. @@ -26,7 +27,7 @@ func Render(markdown string) string { for { if i > len(markdown) { - r.closeParagraphs() + r.closeAll() for range r.quoteLevel { r.out.WriteString("") @@ -72,8 +73,14 @@ func (r *renderer) processLine(line string) { r.quoteLevel = newQuoteLevel - if strings.HasPrefix(line, "#") { - r.closeParagraphs() + if len(line) == 0 { + r.closeAll() + return + } + + switch line[0] { + case '#': + r.closeAll() space := strings.IndexByte(line, ' ') if space > 0 && space <= 6 { @@ -83,10 +90,19 @@ func (r *renderer) processLine(line string) { } return - } - if len(line) == 0 { + case '-', '*': r.closeParagraphs() + line = strings.TrimSpace(line[1:]) + + if r.listLevel == 0 { + r.out.WriteString("
    ") + r.listLevel++ + } + + r.out.WriteString("
  • ") + r.writeText(line) + r.out.WriteString("
  • ") return } @@ -101,6 +117,12 @@ func (r *renderer) processLine(line string) { r.writeText(line) } +// closeAll closes all open tags. +func (r *renderer) closeAll() { + r.closeLists() + r.closeParagraphs() +} + // closeParagraphs closes open paragraphs. func (r *renderer) closeParagraphs() { for range r.paragraphLevel { @@ -110,6 +132,15 @@ func (r *renderer) closeParagraphs() { r.paragraphLevel = 0 } +// closeLists closes open lists. +func (r *renderer) closeLists() { + for range r.listLevel { + r.out.WriteString("
") + } + + r.listLevel = 0 +} + // writeText converts inline markdown to HTML. func (r *renderer) writeText(markdown string) { var ( @@ -170,6 +201,7 @@ func (r *renderer) writeText(markdown string) { } } +// sanitizeURL makes a URL safe to use as the value for a `href` attribute. func sanitizeURL(linkURL string) string { linkURL = strings.TrimSpace(linkURL) diff --git a/Render_test.go b/Render_test.go index 4ccecaa..95be167 100644 --- a/Render_test.go +++ b/Render_test.go @@ -38,6 +38,12 @@ func TestLink(t *testing.T) { assert.Equal(t, markdown.Render("Prefix [text](https://example.com/) suffix."), "

Prefix text suffix.

") } +func TestList(t *testing.T) { + assert.Equal(t, markdown.Render("- Entry"), "
  • Entry
") + assert.Equal(t, markdown.Render("- Entry 1\n- Entry 2"), "
  • Entry 1
  • Entry 2
") + assert.Equal(t, markdown.Render("- Entry 1\n- Entry 2\n- Entry 3"), "
  • Entry 1
  • Entry 2
  • Entry 3
") +} + func TestQuote(t *testing.T) { assert.Equal(t, markdown.Render("> Line"), "

Line

") assert.Equal(t, markdown.Render("> Line 1\n> Line 2"), "

Line 1 Line 2

") @@ -51,6 +57,9 @@ func TestCombined(t *testing.T) { assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "

Header

Line 1. Line 2. Line 3.

") assert.Equal(t, markdown.Render("# Header 1\nLine 1.\n# Header 2\nLine 2."), "

Header 1

Line 1.

Header 2

Line 2.

") assert.Equal(t, markdown.Render("# [Header Link](https://example.com/)"), "

Header Link

") + assert.Equal(t, markdown.Render("# Title\n\n- Entry 1\n- Entry 2\n\nText."), "

Title

  • Entry 1
  • Entry 2

Text.

") + assert.Equal(t, markdown.Render("- Entry\n# Header"), "
  • Entry

Header

") + assert.Equal(t, markdown.Render("> - Entry\n> # Header"), "
  • Entry

Header

") } func TestSecurity(t *testing.T) { From 7af85bb1ddeec43e30439089b00b39f530de587f Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 20:59:32 +0200 Subject: [PATCH 15/55] Implemented tables --- README.md | 2 +- Render.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++--- Render_test.go | 5 ++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c5f42be..cbadd97 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ coverage: 100.0% of statements ## Benchmarks ``` -BenchmarkSmall-12 2585194 462.4 ns/op 248 B/op 5 allocs/op +BenchmarkSmall-12 2411152 489.1 ns/op 248 B/op 5 allocs/op ``` ## License diff --git a/Render.go b/Render.go index d13efe2..a9c7a49 100644 --- a/Render.go +++ b/Render.go @@ -11,10 +11,12 @@ var ( ) type renderer struct { - out strings.Builder - paragraphLevel int - quoteLevel int - listLevel int + out strings.Builder + paragraphLevel int + quoteLevel int + listLevel int + tableLevel int + tableHeaderWritten bool } // Render creates HTML from the supplied markdown text. @@ -104,6 +106,51 @@ func (r *renderer) processLine(line string) { r.writeText(line) r.out.WriteString("") return + + case '|': + r.closeParagraphs() + line = line[1:] + + if r.tableLevel == 0 { + r.out.WriteString("") + r.tableLevel++ + } + + column := 0 + + for { + pipe := strings.IndexByte(line, '|') + + if pipe == -1 { + r.out.WriteString("") + return + } + + content := strings.TrimSpace(line[:pipe]) + + if strings.HasPrefix(content, "---") { + r.out.WriteString("") + r.tableHeaderWritten = true + return + } + + if column == 0 { + r.out.WriteString("") + } + + if r.tableHeaderWritten { + r.out.WriteString("") + } else { + r.out.WriteString("") + } + + line = line[pipe+1:] + column++ + } } if r.paragraphLevel == 0 { @@ -121,6 +168,7 @@ func (r *renderer) processLine(line string) { func (r *renderer) closeAll() { r.closeLists() r.closeParagraphs() + r.closeTables() } // closeParagraphs closes open paragraphs. @@ -141,6 +189,16 @@ func (r *renderer) closeLists() { r.listLevel = 0 } +// closeTables closes open tables. +func (r *renderer) closeTables() { + for range r.tableLevel { + r.out.WriteString("
") + r.writeText(content) + r.out.WriteString("") + r.writeText(content) + r.out.WriteString("
") + } + + r.tableLevel = 0 + r.tableHeaderWritten = false +} + // writeText converts inline markdown to HTML. func (r *renderer) writeText(markdown string) { var ( diff --git a/Render_test.go b/Render_test.go index 95be167..c9718ee 100644 --- a/Render_test.go +++ b/Render_test.go @@ -44,6 +44,11 @@ func TestList(t *testing.T) { assert.Equal(t, markdown.Render("- Entry 1\n- Entry 2\n- Entry 3"), "
  • Entry 1
  • Entry 2
  • Entry 3
") } +func TestTables(t *testing.T) { + assert.Equal(t, markdown.Render("| Head |\n| --- |\n| Body |"), "
Head
Body
") + assert.Equal(t, markdown.Render("| 1 | 2 |\n| --- | --- |\n| 1 | 2 |"), "
12
12
") +} + func TestQuote(t *testing.T) { assert.Equal(t, markdown.Render("> Line"), "

Line

") assert.Equal(t, markdown.Render("> Line 1\n> Line 2"), "

Line 1 Line 2

") From e19a41c792dd1f92259522fae892e37f83e5a4c4 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 20:59:32 +0200 Subject: [PATCH 16/55] Implemented tables --- README.md | 2 +- Render.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++--- Render_test.go | 5 ++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c5f42be..cbadd97 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ coverage: 100.0% of statements ## Benchmarks ``` -BenchmarkSmall-12 2585194 462.4 ns/op 248 B/op 5 allocs/op +BenchmarkSmall-12 2411152 489.1 ns/op 248 B/op 5 allocs/op ``` ## License diff --git a/Render.go b/Render.go index d13efe2..a9c7a49 100644 --- a/Render.go +++ b/Render.go @@ -11,10 +11,12 @@ var ( ) type renderer struct { - out strings.Builder - paragraphLevel int - quoteLevel int - listLevel int + out strings.Builder + paragraphLevel int + quoteLevel int + listLevel int + tableLevel int + tableHeaderWritten bool } // Render creates HTML from the supplied markdown text. @@ -104,6 +106,51 @@ func (r *renderer) processLine(line string) { r.writeText(line) r.out.WriteString("") return + + case '|': + r.closeParagraphs() + line = line[1:] + + if r.tableLevel == 0 { + r.out.WriteString("") + r.tableLevel++ + } + + column := 0 + + for { + pipe := strings.IndexByte(line, '|') + + if pipe == -1 { + r.out.WriteString("") + return + } + + content := strings.TrimSpace(line[:pipe]) + + if strings.HasPrefix(content, "---") { + r.out.WriteString("") + r.tableHeaderWritten = true + return + } + + if column == 0 { + r.out.WriteString("") + } + + if r.tableHeaderWritten { + r.out.WriteString("") + } else { + r.out.WriteString("") + } + + line = line[pipe+1:] + column++ + } } if r.paragraphLevel == 0 { @@ -121,6 +168,7 @@ func (r *renderer) processLine(line string) { func (r *renderer) closeAll() { r.closeLists() r.closeParagraphs() + r.closeTables() } // closeParagraphs closes open paragraphs. @@ -141,6 +189,16 @@ func (r *renderer) closeLists() { r.listLevel = 0 } +// closeTables closes open tables. +func (r *renderer) closeTables() { + for range r.tableLevel { + r.out.WriteString("
") + r.writeText(content) + r.out.WriteString("") + r.writeText(content) + r.out.WriteString("
") + } + + r.tableLevel = 0 + r.tableHeaderWritten = false +} + // writeText converts inline markdown to HTML. func (r *renderer) writeText(markdown string) { var ( diff --git a/Render_test.go b/Render_test.go index 95be167..c9718ee 100644 --- a/Render_test.go +++ b/Render_test.go @@ -44,6 +44,11 @@ func TestList(t *testing.T) { assert.Equal(t, markdown.Render("- Entry 1\n- Entry 2\n- Entry 3"), "
  • Entry 1
  • Entry 2
  • Entry 3
") } +func TestTables(t *testing.T) { + assert.Equal(t, markdown.Render("| Head |\n| --- |\n| Body |"), "
Head
Body
") + assert.Equal(t, markdown.Render("| 1 | 2 |\n| --- | --- |\n| 1 | 2 |"), "
12
12
") +} + func TestQuote(t *testing.T) { assert.Equal(t, markdown.Render("> Line"), "

Line

") assert.Equal(t, markdown.Render("> Line 1\n> Line 2"), "

Line 1 Line 2

") From 67f60d4364c6c121829e76c69553702a424be85d Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 21:27:10 +0200 Subject: [PATCH 17/55] Implemented code blocks --- README.md | 9 +++++++-- Render.go | 38 ++++++++++++++++++++++++++++++++++++++ Render_test.go | 5 +++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cbadd97..cb6a627 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ Markdown renderer. ## Features +- Code - Links - Lists - Headers - Paragraphs - Quotes +- Tables ## Installation @@ -26,9 +28,12 @@ html := markdown.Render("# Header") ``` PASS: TestEmpty -PASS: TestParagraphs +PASS: TestParagraph PASS: TestHeader PASS: TestLink +PASS: TestList +PASS: TestTables +PASS: TestCode PASS: TestQuote PASS: TestCombined PASS: TestSecurity @@ -38,7 +43,7 @@ coverage: 100.0% of statements ## Benchmarks ``` -BenchmarkSmall-12 2411152 489.1 ns/op 248 B/op 5 allocs/op +BenchmarkSmall-12 2421213 494.2 ns/op 248 B/op 5 allocs/op ``` ## License diff --git a/Render.go b/Render.go index a9c7a49..df118b7 100644 --- a/Render.go +++ b/Render.go @@ -16,7 +16,9 @@ type renderer struct { quoteLevel int listLevel int tableLevel int + codeLines int tableHeaderWritten bool + inCodeBlock bool } // Render creates HTML from the supplied markdown text. @@ -52,6 +54,23 @@ func Render(markdown string) string { } func (r *renderer) processLine(line string) { + if r.inCodeBlock { + if strings.HasPrefix(line, "```") { + r.out.WriteString("") + r.inCodeBlock = false + r.codeLines = 0 + } else { + if r.codeLines != 0 { + r.out.WriteByte('\n') + } + + r.out.WriteString(html.EscapeString(line)) + r.codeLines++ + } + + return + } + newQuoteLevel := 0 for strings.HasPrefix(line, ">") { @@ -107,6 +126,25 @@ func (r *renderer) processLine(line string) { r.out.WriteString("") return + case '`': + if strings.HasPrefix(line, "```") { + language := line[3:] + + if !r.inCodeBlock { + if language != "" { + r.out.WriteString("
")
+				} else {
+					r.out.WriteString("
")
+				}
+
+				r.inCodeBlock = true
+			}
+
+			return
+		}
+
 	case '|':
 		r.closeParagraphs()
 		line = line[1:]
diff --git a/Render_test.go b/Render_test.go
index c9718ee..30a2cac 100644
--- a/Render_test.go
+++ b/Render_test.go
@@ -49,6 +49,11 @@ func TestTables(t *testing.T) {
 	assert.Equal(t, markdown.Render("| 1 | 2 |\n| --- | --- |\n| 1 | 2 |"), "
12
12
") } +func TestCode(t *testing.T) { + assert.Equal(t, markdown.Render("```\nText\n```"), "
Text
") + assert.Equal(t, markdown.Render("```go\ntype A struct {\n\t\n}\n```"), "
type A struct {\n\t\n}
") +} + func TestQuote(t *testing.T) { assert.Equal(t, markdown.Render("> Line"), "

Line

") assert.Equal(t, markdown.Render("> Line 1\n> Line 2"), "

Line 1 Line 2

") From f75ea823a98bd078497edef8d809f18f3f442d91 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Mon, 1 Apr 2024 21:27:10 +0200 Subject: [PATCH 18/55] Implemented code blocks --- README.md | 9 +++++++-- Render.go | 38 ++++++++++++++++++++++++++++++++++++++ Render_test.go | 5 +++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cbadd97..cb6a627 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ Markdown renderer. ## Features +- Code - Links - Lists - Headers - Paragraphs - Quotes +- Tables ## Installation @@ -26,9 +28,12 @@ html := markdown.Render("# Header") ``` PASS: TestEmpty -PASS: TestParagraphs +PASS: TestParagraph PASS: TestHeader PASS: TestLink +PASS: TestList +PASS: TestTables +PASS: TestCode PASS: TestQuote PASS: TestCombined PASS: TestSecurity @@ -38,7 +43,7 @@ coverage: 100.0% of statements ## Benchmarks ``` -BenchmarkSmall-12 2411152 489.1 ns/op 248 B/op 5 allocs/op +BenchmarkSmall-12 2421213 494.2 ns/op 248 B/op 5 allocs/op ``` ## License diff --git a/Render.go b/Render.go index a9c7a49..df118b7 100644 --- a/Render.go +++ b/Render.go @@ -16,7 +16,9 @@ type renderer struct { quoteLevel int listLevel int tableLevel int + codeLines int tableHeaderWritten bool + inCodeBlock bool } // Render creates HTML from the supplied markdown text. @@ -52,6 +54,23 @@ func Render(markdown string) string { } func (r *renderer) processLine(line string) { + if r.inCodeBlock { + if strings.HasPrefix(line, "```") { + r.out.WriteString("
") + r.inCodeBlock = false + r.codeLines = 0 + } else { + if r.codeLines != 0 { + r.out.WriteByte('\n') + } + + r.out.WriteString(html.EscapeString(line)) + r.codeLines++ + } + + return + } + newQuoteLevel := 0 for strings.HasPrefix(line, ">") { @@ -107,6 +126,25 @@ func (r *renderer) processLine(line string) { r.out.WriteString("") return + case '`': + if strings.HasPrefix(line, "```") { + language := line[3:] + + if !r.inCodeBlock { + if language != "" { + r.out.WriteString("
")
+				} else {
+					r.out.WriteString("
")
+				}
+
+				r.inCodeBlock = true
+			}
+
+			return
+		}
+
 	case '|':
 		r.closeParagraphs()
 		line = line[1:]
diff --git a/Render_test.go b/Render_test.go
index c9718ee..30a2cac 100644
--- a/Render_test.go
+++ b/Render_test.go
@@ -49,6 +49,11 @@ func TestTables(t *testing.T) {
 	assert.Equal(t, markdown.Render("| 1 | 2 |\n| --- | --- |\n| 1 | 2 |"), "
12
12
") } +func TestCode(t *testing.T) { + assert.Equal(t, markdown.Render("```\nText\n```"), "
Text
") + assert.Equal(t, markdown.Render("```go\ntype A struct {\n\t\n}\n```"), "
type A struct {\n\t\n}
") +} + func TestQuote(t *testing.T) { assert.Equal(t, markdown.Render("> Line"), "

Line

") assert.Equal(t, markdown.Render("> Line 1\n> Line 2"), "

Line 1 Line 2

") From 468f571587ebf4df226d5d5bae89bac408738958 Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Tue, 2 Apr 2024 10:44:47 +0200 Subject: [PATCH 19/55] Improved performance --- README.md | 6 ++- Render.go | 101 ++++++++++++++++++++++++++++----------------- benchmarks_test.go | 28 ++++++++++++- testdata/large.md | 37 +++++++++++++++++ testdata/medium.md | 3 ++ testdata/small.md | 3 ++ 6 files changed, 138 insertions(+), 40 deletions(-) create mode 100644 testdata/large.md create mode 100644 testdata/medium.md create mode 100644 testdata/small.md 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("
") } } else if newQuoteLevel < r.quoteLevel { r.closeParagraphs() for range r.quoteLevel - newQuoteLevel { - 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("