Implemented block quotes

This commit is contained in:
2024-04-01 18:10:52 +02:00
parent 302fd393c8
commit abb1b0c3fc
3 changed files with 102 additions and 52 deletions

View File

@ -7,6 +7,7 @@ Markdown renderer.
- Links - Links
- Headers - Headers
- Paragraphs - Paragraphs
- Quotes
## Installation ## Installation
@ -27,6 +28,7 @@ PASS: TestEmpty
PASS: TestParagraphs PASS: TestParagraphs
PASS: TestHeader PASS: TestHeader
PASS: TestLink PASS: TestLink
PASS: TestQuote
PASS: TestCombined PASS: TestCombined
PASS: TestSecurity PASS: TestSecurity
coverage: 100.0% of statements coverage: 100.0% of statements
@ -35,7 +37,7 @@ coverage: 100.0% of statements
## Benchmarks ## 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 ## License

142
Render.go
View File

@ -10,30 +10,29 @@ var (
headerEnd = []string{"</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"} headerEnd = []string{"</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"}
) )
type renderer struct {
out strings.Builder
paragraphLevel int
quoteLevel int
}
// Render creates HTML from the supplied markdown text. // Render creates HTML from the supplied markdown text.
func Render(markdown string) string { func Render(markdown string) string {
var ( var (
out = strings.Builder{} r renderer
paragraph = strings.Builder{}
i = 0 i = 0
lineStart = 0 lineStart = 0
) )
flush := func() {
if paragraph.Len() == 0 {
return
}
out.WriteString("<p>")
writeText(&out, paragraph.String())
out.WriteString("</p>")
paragraph.Reset()
}
for { for {
if i > len(markdown) { if i > len(markdown) {
flush() r.closeParagraphs()
return out.String()
for range r.quoteLevel {
r.out.WriteString("</blockquote>")
}
return r.out.String()
} }
if i != len(markdown) && markdown[i] != '\n' { if i != len(markdown) && markdown[i] != '\n' {
@ -45,33 +44,74 @@ func Render(markdown string) string {
lineStart = i + 1 lineStart = i + 1
i++ i++
switch { r.processLine(line)
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)
}
} }
} }
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("<blockquote>")
}
} else if newQuoteLevel < r.quoteLevel {
r.closeParagraphs()
for range r.quoteLevel - newQuoteLevel {
r.out.WriteString("</blockquote>")
}
}
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("<p>")
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("</p>")
}
r.paragraphLevel = 0
}
// writeText converts inline markdown to HTML.
func (r *renderer) writeText(markdown string) {
var ( var (
i = 0 i = 0
tokenStart = 0 tokenStart = 0
@ -85,16 +125,16 @@ func writeText(out *strings.Builder, text string) {
) )
for { for {
if i == len(text) { if i == len(markdown) {
out.WriteString(html.EscapeString(text[tokenStart:])) r.out.WriteString(html.EscapeString(markdown[tokenStart:]))
return return
} }
c := text[i] c := markdown[i]
switch c { switch c {
case '[': case '[':
out.WriteString(html.EscapeString(text[tokenStart:i])) r.out.WriteString(html.EscapeString(markdown[tokenStart:i]))
tokenStart = i tokenStart = i
textStart = i textStart = i
case ']': case ']':
@ -109,14 +149,14 @@ func writeText(out *strings.Builder, text string) {
parentheses-- parentheses--
if parentheses == 0 && textStart >= 0 && textEnd >= 0 && urlStart >= 0 { if parentheses == 0 && textStart >= 0 && textEnd >= 0 && urlStart >= 0 {
linkText := text[textStart+1 : textEnd] linkText := markdown[textStart+1 : textEnd]
linkURL := text[urlStart+1 : i] linkURL := markdown[urlStart+1 : i]
out.WriteString("<a href=\"") r.out.WriteString("<a href=\"")
out.WriteString(formatURL(linkURL)) r.out.WriteString(sanitizeURL(linkURL))
out.WriteString("\">") r.out.WriteString("\">")
out.WriteString(html.EscapeString(linkText)) r.out.WriteString(html.EscapeString(linkText))
out.WriteString("</a>") r.out.WriteString("</a>")
textStart = -1 textStart = -1
textEnd = -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:") { if strings.HasPrefix(strings.ToLower(linkURL), "javascript:") {
return "" return ""
} }

View File

@ -38,6 +38,14 @@ func TestLink(t *testing.T) {
assert.Equal(t, markdown.Render("Prefix [text](https://example.com/) suffix."), "<p>Prefix <a href=\"https://example.com/\">text</a> suffix.</p>") assert.Equal(t, markdown.Render("Prefix [text](https://example.com/) suffix."), "<p>Prefix <a href=\"https://example.com/\">text</a> suffix.</p>")
} }
func TestQuote(t *testing.T) {
assert.Equal(t, markdown.Render("> Line"), "<blockquote><p>Line</p></blockquote>")
assert.Equal(t, markdown.Render("> Line 1\n> Line 2"), "<blockquote><p>Line 1 Line 2</p></blockquote>")
assert.Equal(t, markdown.Render("> Line 1\n\nLine 2"), "<blockquote><p>Line 1</p></blockquote><p>Line 2</p>")
assert.Equal(t, markdown.Render("> Line 1\n>>Line 2"), "<blockquote><p>Line 1</p><blockquote><p>Line 2</p></blockquote></blockquote>")
assert.Equal(t, markdown.Render("Line 1\n> Line 2\n> Line 3\nLine 4"), "<p>Line 1</p><blockquote><p>Line 2 Line 3</p></blockquote><p>Line 4</p>")
}
func TestCombined(t *testing.T) { func TestCombined(t *testing.T) {
assert.Equal(t, markdown.Render("# Header\n\nLine 1."), "<h1>Header</h1><p>Line 1.</p>") assert.Equal(t, markdown.Render("# Header\n\nLine 1."), "<h1>Header</h1><p>Line 1.</p>")
assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "<h1>Header</h1><p>Line 1. Line 2. Line 3.</p>") assert.Equal(t, markdown.Render("# Header\nLine 1.\nLine 2.\nLine 3."), "<h1>Header</h1><p>Line 1. Line 2. Line 3.</p>")