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.

")