Compare commits

...

10 commits

Author SHA1 Message Date
5e3c6d42f7
Updated dependencies
All checks were successful
/ test (push) Successful in 21s
2025-06-12 11:43:43 +02:00
4aea4ae42c
Updated formatting 2025-06-12 11:42:56 +02:00
0b31c9f888
Updated module path 2025-02-25 16:51:04 +01:00
Eduard Urbach
809b89d689
Implemented image links 2024-04-03 21:13:59 +02:00
Eduard Urbach
463c8b4a85
Implemented ordered lists 2024-04-03 09:23:52 +02:00
Eduard Urbach
1c7f9f2f7d
Disabled formatting in inline codes 2024-04-02 21:46:23 +02:00
Eduard Urbach
9e6767fb12
Disabled formatting in URLs 2024-04-02 21:30:50 +02:00
Eduard Urbach
891e3938fa
Simplified feature list 2024-04-02 20:52:48 +02:00
Eduard Urbach
d7fd8c74e8
Implemented strikethrough text 2024-04-02 20:50:33 +02:00
Eduard Urbach
3b776ef8cd
Simplified switch statement 2024-04-02 20:31:09 +02:00
6 changed files with 187 additions and 78 deletions

View file

@ -4,20 +4,23 @@ A markdown renderer that supports only a subset of the CommonMark spec in order
## Features ## Features
- Code blocks - Bold
- Formatting (bold, italic, monospace) - Code
- Italic
- Links - Links
- Lists - Lists
- Images
- Headers - Headers
- Paragraphs - Paragraphs
- Quotes - Quotes
- Separators - Separators
- Strikethrough
- Tables - Tables
## Installation ## Installation
```shell ```shell
go get git.akyoto.dev/go/markdown go get git.urbach.dev/go/markdown
``` ```
## Usage ## Usage
@ -34,7 +37,9 @@ PASS: TestParagraph
PASS: TestHeader PASS: TestHeader
PASS: TestItalic PASS: TestItalic
PASS: TestBold PASS: TestBold
PASS: TestStrike
PASS: TestLink PASS: TestLink
PASS: TestImage
PASS: TestList PASS: TestList
PASS: TestTables PASS: TestTables
PASS: TestCode PASS: TestCode
@ -48,14 +53,14 @@ coverage: 100.0% of statements
## Benchmarks ## Benchmarks
``` ```
BenchmarkSmall-12 5986922 187.5 ns/op 32 B/op 1 allocs/op BenchmarkSmall-12 5884641 201.5 ns/op 32 B/op 1 allocs/op
BenchmarkMedium-12 1000000 1077 ns/op 512 B/op 1 allocs/op BenchmarkMedium-12 938371 1124 ns/op 512 B/op 1 allocs/op
BenchmarkLarge-12 255178 4055 ns/op 2560 B/op 2 allocs/op BenchmarkLarge-12 277065 4115 ns/op 2560 B/op 2 allocs/op
``` ```
## License ## License
Please see the [license documentation](https://akyoto.dev/license). Please see the [license documentation](https://urbach.dev/license).
## Copyright ## Copyright

145
Render.go
View file

@ -17,6 +17,7 @@ type renderer struct {
paragraphLevel int paragraphLevel int
quoteLevel int quoteLevel int
listLevel int listLevel int
olistLevel int
tableLevel int tableLevel int
codeLines int codeLines int
tableHeaderWritten bool tableHeaderWritten bool
@ -200,6 +201,26 @@ func (r *renderer) processLine(line string) {
} }
} }
pos := 0
for pos < len(line) && line[pos] >= '0' && line[pos] <= '9' {
pos++
if pos < len(line) && (line[pos] == '.' || line[pos] == ')') {
line = strings.TrimSpace(line[pos+1:])
if r.olistLevel == 0 {
r.WriteString("<ol>")
r.olistLevel++
}
r.WriteString("<li>")
r.writeText(line)
r.WriteString("</li>")
return
}
}
if r.paragraphLevel == 0 { if r.paragraphLevel == 0 {
r.WriteString("<p>") r.WriteString("<p>")
r.paragraphLevel++ r.paragraphLevel++
@ -233,7 +254,12 @@ func (r *renderer) closeLists() {
r.WriteString("</ul>") r.WriteString("</ul>")
} }
for range r.olistLevel {
r.WriteString("</ol>")
}
r.listLevel = 0 r.listLevel = 0
r.olistLevel = 0
} }
// closeTables closes open tables. // closeTables closes open tables.
@ -253,15 +279,15 @@ func (r *renderer) writeText(markdown string) {
searchStart = 0 searchStart = 0
linkTextStart = -1 linkTextStart = -1
linkTextEnd = -1 linkTextEnd = -1
urlStart = -1 linkIsImage = false
codeStart = -1
emStart = -1 emStart = -1
strongStart = -1 strongStart = -1
parentheses = 0 strikeStart = -1
) )
begin:
for { for {
i := strings.IndexAny(markdown[searchStart:], "[]()`*_") i := strings.IndexAny(markdown[searchStart:], "[]()`*_~!")
if i == -1 { if i == -1 {
r.WriteString(html.EscapeString(markdown[tokenStart:])) r.WriteString(html.EscapeString(markdown[tokenStart:]))
@ -270,9 +296,8 @@ func (r *renderer) writeText(markdown string) {
i += searchStart i += searchStart
searchStart = i + 1 searchStart = i + 1
c := markdown[i]
switch c { switch markdown[i] {
case '[': case '[':
r.WriteString(html.EscapeString(markdown[tokenStart:i])) r.WriteString(html.EscapeString(markdown[tokenStart:i]))
tokenStart = i tokenStart = i
@ -282,45 +307,71 @@ func (r *renderer) writeText(markdown string) {
linkTextEnd = i linkTextEnd = i
case '(': case '(':
if parentheses == 0 { if linkTextStart == -1 || linkTextEnd == -1 {
urlStart = i continue
} }
parentheses++ level := 1
case ')': for {
parentheses-- pos := strings.IndexAny(markdown[searchStart:], "()")
if parentheses == 0 && linkTextStart >= 0 && linkTextEnd >= 0 && urlStart >= 0 { if pos == -1 {
linkText := markdown[linkTextStart+1 : linkTextEnd] goto begin
linkURL := markdown[urlStart+1 : i] }
r.WriteString("<a href=\"") switch markdown[searchStart+pos] {
r.WriteString(sanitizeURL(linkURL)) case '(':
r.WriteString("\">") level++
r.WriteString(html.EscapeString(linkText)) case ')':
r.WriteString("</a>") level--
linkTextStart = -1 if level == 0 {
linkTextEnd = -1 urlEnd := searchStart + pos
urlStart = -1 searchStart = urlEnd + 1
tokenStart = i + 1 linkText := markdown[linkTextStart+1 : linkTextEnd]
linkURL := markdown[i+1 : urlEnd]
if linkIsImage {
r.WriteString("<img src=\"")
r.WriteString(sanitizeURL(linkURL))
r.WriteString("\" alt=\"")
r.WriteString(html.EscapeString(linkText))
r.WriteString("\">")
} else {
r.WriteString("<a href=\"")
r.WriteString(sanitizeURL(linkURL))
r.WriteString("\">")
r.WriteString(html.EscapeString(linkText))
r.WriteString("</a>")
}
linkTextStart = -1
linkTextEnd = -1
tokenStart = urlEnd + 1
goto begin
}
}
searchStart += pos + 1
} }
case '`': case '`':
if codeStart != -1 { end := strings.IndexByte(markdown[searchStart:], '`')
r.WriteString("<code>")
r.WriteString(html.EscapeString(markdown[codeStart:i])) if end == -1 {
r.WriteString("</code>") continue
codeStart = -1
tokenStart = i + 1
} else {
r.WriteString(html.EscapeString(markdown[tokenStart:i]))
tokenStart = i
codeStart = i + 1
} }
r.WriteString(html.EscapeString(markdown[tokenStart:i]))
r.WriteString("<code>")
r.WriteString(html.EscapeString(markdown[searchStart : searchStart+end]))
r.WriteString("</code>")
searchStart += end + 1
tokenStart = searchStart
case '*', '_': case '*', '_':
if i == emStart { if i == emStart {
strongStart = i + 1 strongStart = i + 1
@ -343,6 +394,34 @@ func (r *renderer) writeText(markdown string) {
tokenStart = i tokenStart = i
emStart = i + 1 emStart = i + 1
} }
case '~':
if i+1 >= len(markdown) || markdown[i+1] != '~' {
continue
}
if strikeStart != -1 {
r.WriteString("<del>")
r.WriteString(html.EscapeString(markdown[strikeStart:i]))
r.WriteString("</del>")
strikeStart = -1
tokenStart = i + 2
} else {
r.WriteString(html.EscapeString(markdown[tokenStart:i]))
tokenStart = i
strikeStart = i + 2
}
case '!':
if i+1 >= len(markdown) || markdown[i+1] != '[' {
continue
}
r.WriteString(html.EscapeString(markdown[tokenStart:i]))
tokenStart = i
linkTextStart = i + 1
searchStart++
linkIsImage = true
} }
} }
} }

View file

@ -3,8 +3,8 @@ package markdown_test
import ( import (
"testing" "testing"
"git.akyoto.dev/go/assert" "git.urbach.dev/go/assert"
"git.akyoto.dev/go/markdown" "git.urbach.dev/go/markdown"
) )
func TestEmpty(t *testing.T) { func TestEmpty(t *testing.T) {
@ -39,20 +39,44 @@ func TestBold(t *testing.T) {
assert.Equal(t, markdown.Render("__bold__"), "<p><strong>bold</strong></p>") assert.Equal(t, markdown.Render("__bold__"), "<p><strong>bold</strong></p>")
} }
func TestStrike(t *testing.T) {
assert.Equal(t, markdown.Render("~normal text~"), "<p>~normal text~</p>")
assert.Equal(t, markdown.Render("~~deleted text~~"), "<p><del>deleted text</del></p>")
}
func TestLink(t *testing.T) { func TestLink(t *testing.T) {
assert.Equal(t, markdown.Render("[text](https://example.com/)"), "<p><a href=\"https://example.com/\">text</a></p>") assert.Equal(t, markdown.Render("[text](https://example.com/)"), `<p><a href="https://example.com/">text</a></p>`)
assert.Equal(t, markdown.Render("[text](https://example.com/"), "<p>[text](https://example.com/</p>") assert.Equal(t, markdown.Render("[text](https://example.com/"), `<p>[text](https://example.com/</p>`)
assert.Equal(t, markdown.Render("[text]https://example.com/)"), "<p>[text]https://example.com/)</p>") assert.Equal(t, markdown.Render("[text]https://example.com/)"), `<p>[text]https://example.com/)</p>`)
assert.Equal(t, markdown.Render("[text(https://example.com/)"), "<p>[text(https://example.com/)</p>") assert.Equal(t, markdown.Render("[text(https://example.com/)"), `<p>[text(https://example.com/)</p>`)
assert.Equal(t, markdown.Render("text](https://example.com/)"), "<p>text](https://example.com/)</p>") assert.Equal(t, markdown.Render("text](https://example.com/)"), `<p>text](https://example.com/)</p>`)
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("[text](https://example.com/_test_)"), `<p><a href="https://example.com/_test_">text</a></p>`)
assert.Equal(t, markdown.Render("Prefix [text](https://example.com/) suffix."), `<p>Prefix <a href="https://example.com/">text</a> suffix.</p>`)
}
func TestImage(t *testing.T) {
assert.Equal(t, markdown.Render("!"), `<p>!</p>`)
assert.Equal(t, markdown.Render("!["), `<p>![</p>`)
assert.Equal(t, markdown.Render("![]"), `<p>![]</p>`)
assert.Equal(t, markdown.Render("![]("), `<p>![](</p>`)
assert.Equal(t, markdown.Render("![]()"), `<p><img src="" alt=""></p>`)
assert.Equal(t, markdown.Render("![title](https://example.com/image.png)"), `<p><img src="https://example.com/image.png" alt="title"></p>`)
} }
func TestList(t *testing.T) { func TestList(t *testing.T) {
assert.Equal(t, markdown.Render("-"), "<p>-</p>")
assert.Equal(t, markdown.Render("- "), "<ul><li></li></ul>")
assert.Equal(t, markdown.Render("- Entry"), "<ul><li>Entry</li></ul>") assert.Equal(t, markdown.Render("- Entry"), "<ul><li>Entry</li></ul>")
assert.Equal(t, markdown.Render("- Entry 1\n- Entry 2"), "<ul><li>Entry 1</li><li>Entry 2</li></ul>") assert.Equal(t, markdown.Render("- Entry 1\n- Entry 2"), "<ul><li>Entry 1</li><li>Entry 2</li></ul>")
assert.Equal(t, markdown.Render("- Entry 1\n- Entry 2\n- Entry 3"), "<ul><li>Entry 1</li><li>Entry 2</li><li>Entry 3</li></ul>") }
assert.Equal(t, markdown.Render("-"), "<p>-</p>")
func TestOrderedList(t *testing.T) {
assert.Equal(t, markdown.Render("1"), "<p>1</p>")
assert.Equal(t, markdown.Render("1."), "<ol><li></li></ol>")
assert.Equal(t, markdown.Render("1. "), "<ol><li></li></ol>")
assert.Equal(t, markdown.Render("1. Entry"), "<ol><li>Entry</li></ol>")
assert.Equal(t, markdown.Render("999) Entry"), "<ol><li>Entry</li></ol>")
assert.Equal(t, markdown.Render("1. Entry\n2. Entry"), "<ol><li>Entry</li><li>Entry</li></ol>")
} }
func TestTables(t *testing.T) { func TestTables(t *testing.T) {
@ -64,6 +88,7 @@ func TestCode(t *testing.T) {
assert.Equal(t, markdown.Render("```\nText\n```"), "<pre><code>Text</code></pre>") assert.Equal(t, markdown.Render("```\nText\n```"), "<pre><code>Text</code></pre>")
assert.Equal(t, markdown.Render("```go\ntype A struct {\n\t\n}\n```"), "<pre><code class=\"language-go\">type A struct {\n\t\n}</code></pre>") assert.Equal(t, markdown.Render("```go\ntype A struct {\n\t\n}\n```"), "<pre><code class=\"language-go\">type A struct {\n\t\n}</code></pre>")
assert.Equal(t, markdown.Render("`monospace`"), "<p><code>monospace</code></p>") assert.Equal(t, markdown.Render("`monospace`"), "<p><code>monospace</code></p>")
assert.Equal(t, markdown.Render("`unfinished"), "<p>`unfinished</p>")
assert.Equal(t, markdown.Render("Inline `monospace` text."), "<p>Inline <code>monospace</code> text.</p>") assert.Equal(t, markdown.Render("Inline `monospace` text."), "<p>Inline <code>monospace</code> text.</p>")
} }
@ -81,22 +106,22 @@ func TestSeparator(t *testing.T) {
} }
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>`)
assert.Equal(t, markdown.Render("# Header 1\nLine 1.\n# Header 2\nLine 2."), "<h1>Header 1</h1><p>Line 1.</p><h1>Header 2</h1><p>Line 2.</p>") assert.Equal(t, markdown.Render("# Header 1\nLine 1.\n# Header 2\nLine 2."), `<h1>Header 1</h1><p>Line 1.</p><h1>Header 2</h1><p>Line 2.</p>`)
assert.Equal(t, markdown.Render("# [Header Link](https://example.com/)"), "<h1><a href=\"https://example.com/\">Header Link</a></h1>") assert.Equal(t, markdown.Render("# [Header Link](https://example.com/)"), `<h1><a href="https://example.com/">Header Link</a></h1>`)
assert.Equal(t, markdown.Render("# Title\n\n- Entry 1\n- Entry 2\n\nText."), "<h1>Title</h1><ul><li>Entry 1</li><li>Entry 2</li></ul><p>Text.</p>") assert.Equal(t, markdown.Render("# Title\n\n- Entry 1\n- Entry 2\n\nText."), `<h1>Title</h1><ul><li>Entry 1</li><li>Entry 2</li></ul><p>Text.</p>`)
assert.Equal(t, markdown.Render("- Entry\n# Header"), "<ul><li>Entry</li></ul><h1>Header</h1>") assert.Equal(t, markdown.Render("- Entry\n# Header"), `<ul><li>Entry</li></ul><h1>Header</h1>`)
assert.Equal(t, markdown.Render("> - Entry\n> # Header"), "<blockquote><ul><li>Entry</li></ul><h1>Header</h1></blockquote>") assert.Equal(t, markdown.Render("> - Entry\n> # Header"), `<blockquote><ul><li>Entry</li></ul><h1>Header</h1></blockquote>`)
assert.Equal(t, markdown.Render("> **bold** and *italic* text."), "<blockquote><p><strong>bold</strong> and <em>italic</em> text.</p></blockquote>") assert.Equal(t, markdown.Render("> **bold** and *italic* text."), `<blockquote><p><strong>bold</strong> and <em>italic</em> text.</p></blockquote>`)
assert.Equal(t, markdown.Render("> __bold__ and _italic_ text."), "<blockquote><p><strong>bold</strong> and <em>italic</em> text.</p></blockquote>") assert.Equal(t, markdown.Render("> __bold__ and _italic_ text."), `<blockquote><p><strong>bold</strong> and <em>italic</em> text.</p></blockquote>`)
} }
func TestSecurity(t *testing.T) { func TestSecurity(t *testing.T) {
assert.Equal(t, markdown.Render("[text](javascript:alert(\"xss\"))"), "<p><a href=\"\">text</a></p>") assert.Equal(t, markdown.Render(`[text](javascript:alert("xss"))`), `<p><a href="">text</a></p>`)
assert.Equal(t, markdown.Render("[text](javAscRipt:alert(\"xss\"))"), "<p><a href=\"\">text</a></p>") assert.Equal(t, markdown.Render(`[text](javAscRipt:alert("xss"))`), `<p><a href="">text</a></p>`)
assert.Equal(t, markdown.Render("[text]( javascript:alert(\"xss\"))"), "<p><a href=\"\">text</a></p>") assert.Equal(t, markdown.Render(`[text]( javascript:alert("xss"))`), `<p><a href="">text</a></p>`)
assert.Equal(t, markdown.Render("[text]('javAscRipt:alert(\"xss\")')"), "<p><a href=\"&#39;javAscRipt:alert(&#34;xss&#34;)&#39;\">text</a></p>") assert.Equal(t, markdown.Render(`[text]('javAscRipt:alert("xss")')`), `<p><a href="&#39;javAscRipt:alert(&#34;xss&#34;)&#39;">text</a></p>`)
assert.Equal(t, markdown.Render("[text](\"><script>alert(123)</script>)"), "<p><a href=\"&#34;&gt;&lt;script&gt;alert(123)&lt;/script&gt;\">text</a></p>") assert.Equal(t, markdown.Render(`[text]("><script>alert(123)</script>)`), `<p><a href="&#34;&gt;&lt;script&gt;alert(123)&lt;/script&gt;">text</a></p>`)
assert.Equal(t, markdown.Render("[<script>alert(123)</script>]()"), "<p><a href=\"\">&lt;script&gt;alert(123)&lt;/script&gt;</a></p>") assert.Equal(t, markdown.Render(`[<script>alert(123)</script>]()`), `<p><a href="">&lt;script&gt;alert(123)&lt;/script&gt;</a></p>`)
} }

View file

@ -4,8 +4,8 @@ import (
"os" "os"
"testing" "testing"
"git.akyoto.dev/go/assert" "git.urbach.dev/go/assert"
"git.akyoto.dev/go/markdown" "git.urbach.dev/go/markdown"
) )
func BenchmarkSmall(b *testing.B) { func BenchmarkSmall(b *testing.B) {
@ -13,7 +13,7 @@ func BenchmarkSmall(b *testing.B) {
assert.Nil(b, err) assert.Nil(b, err)
input := string(small) input := string(small)
for range b.N { for b.Loop() {
markdown.Render(input) markdown.Render(input)
} }
} }
@ -23,7 +23,7 @@ func BenchmarkMedium(b *testing.B) {
assert.Nil(b, err) assert.Nil(b, err)
input := string(medium) input := string(medium)
for range b.N { for b.Loop() {
markdown.Render(input) markdown.Render(input)
} }
} }
@ -33,7 +33,7 @@ func BenchmarkLarge(b *testing.B) {
assert.Nil(b, err) assert.Nil(b, err)
input := string(large) input := string(large)
for range b.N { for b.Loop() {
markdown.Render(input) markdown.Render(input)
} }
} }

6
go.mod
View file

@ -1,5 +1,5 @@
module git.akyoto.dev/go/markdown module git.urbach.dev/go/markdown
go 1.22.1 go 1.24
require git.akyoto.dev/go/assert v0.1.3 require git.urbach.dev/go/assert v0.0.0-20250606150337-559d3d3afcda

4
go.sum
View file

@ -1,2 +1,2 @@
git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8= git.urbach.dev/go/assert v0.0.0-20250606150337-559d3d3afcda h1:VN6ZQwtwLOm2xTms+v8IIeeNjvs55qyEBNArv3dPq9g=
git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM= git.urbach.dev/go/assert v0.0.0-20250606150337-559d3d3afcda/go.mod h1:PNI/NSBOqvoeU58/7eBsIR09Yoq2S/qtSRiTrctkiq0=