diff --git a/README.md b/README.md index b0d3057..ea5cc1b 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,23 @@ A markdown renderer that supports only a subset of the CommonMark spec in order ## Features -- Code blocks -- Formatting (bold, italic, monospace) +- Bold +- Code +- Italic - Links - Lists +- Images - Headers - Paragraphs - Quotes - Separators +- Strikethrough - Tables ## Installation ```shell -go get git.akyoto.dev/go/markdown +go get git.urbach.dev/go/markdown ``` ## Usage @@ -34,7 +37,9 @@ PASS: TestParagraph PASS: TestHeader PASS: TestItalic PASS: TestBold +PASS: TestStrike PASS: TestLink +PASS: TestImage PASS: TestList PASS: TestTables PASS: TestCode @@ -48,14 +53,14 @@ coverage: 100.0% of statements ## Benchmarks ``` -BenchmarkSmall-12 5986922 187.5 ns/op 32 B/op 1 allocs/op -BenchmarkMedium-12 1000000 1077 ns/op 512 B/op 1 allocs/op -BenchmarkLarge-12 255178 4055 ns/op 2560 B/op 2 allocs/op +BenchmarkSmall-12 5884641 201.5 ns/op 32 B/op 1 allocs/op +BenchmarkMedium-12 938371 1124 ns/op 512 B/op 1 allocs/op +BenchmarkLarge-12 277065 4115 ns/op 2560 B/op 2 allocs/op ``` ## License -Please see the [license documentation](https://akyoto.dev/license). +Please see the [license documentation](https://urbach.dev/license). ## Copyright diff --git a/Render.go b/Render.go index 3bc3df4..d9facae 100644 --- a/Render.go +++ b/Render.go @@ -17,6 +17,7 @@ type renderer struct { paragraphLevel int quoteLevel int listLevel int + olistLevel int tableLevel int codeLines int 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("
    ") + r.olistLevel++ + } + + r.WriteString("
  1. ") + r.writeText(line) + r.WriteString("
  2. ") + return + } + } + if r.paragraphLevel == 0 { r.WriteString("

    ") r.paragraphLevel++ @@ -233,7 +254,12 @@ func (r *renderer) closeLists() { r.WriteString("") } + for range r.olistLevel { + r.WriteString("

") + } + r.listLevel = 0 + r.olistLevel = 0 } // closeTables closes open tables. @@ -253,15 +279,15 @@ func (r *renderer) writeText(markdown string) { searchStart = 0 linkTextStart = -1 linkTextEnd = -1 - urlStart = -1 - codeStart = -1 + linkIsImage = false emStart = -1 strongStart = -1 - parentheses = 0 + strikeStart = -1 ) +begin: for { - i := strings.IndexAny(markdown[searchStart:], "[]()`*_") + i := strings.IndexAny(markdown[searchStart:], "[]()`*_~!") if i == -1 { r.WriteString(html.EscapeString(markdown[tokenStart:])) @@ -270,9 +296,8 @@ func (r *renderer) writeText(markdown string) { i += searchStart searchStart = i + 1 - c := markdown[i] - switch c { + switch markdown[i] { case '[': r.WriteString(html.EscapeString(markdown[tokenStart:i])) tokenStart = i @@ -282,45 +307,71 @@ func (r *renderer) writeText(markdown string) { linkTextEnd = i case '(': - if parentheses == 0 { - urlStart = i + if linkTextStart == -1 || linkTextEnd == -1 { + continue } - parentheses++ + level := 1 - case ')': - parentheses-- + for { + pos := strings.IndexAny(markdown[searchStart:], "()") - if parentheses == 0 && linkTextStart >= 0 && linkTextEnd >= 0 && urlStart >= 0 { - linkText := markdown[linkTextStart+1 : linkTextEnd] - linkURL := markdown[urlStart+1 : i] + if pos == -1 { + goto begin + } - r.WriteString("") - r.WriteString(html.EscapeString(linkText)) - r.WriteString("") + switch markdown[searchStart+pos] { + case '(': + level++ + case ')': + level-- - linkTextStart = -1 - linkTextEnd = -1 - urlStart = -1 + if level == 0 { + urlEnd := searchStart + pos + searchStart = urlEnd + 1 - tokenStart = i + 1 + linkText := markdown[linkTextStart+1 : linkTextEnd] + linkURL := markdown[i+1 : urlEnd] + + if linkIsImage { + r.WriteString("\"")") + } else { + r.WriteString("") + r.WriteString(html.EscapeString(linkText)) + r.WriteString("") + } + + linkTextStart = -1 + linkTextEnd = -1 + tokenStart = urlEnd + 1 + goto begin + } + } + + searchStart += pos + 1 } case '`': - if codeStart != -1 { - r.WriteString("") - r.WriteString(html.EscapeString(markdown[codeStart:i])) - r.WriteString("") - codeStart = -1 - tokenStart = i + 1 - } else { - r.WriteString(html.EscapeString(markdown[tokenStart:i])) - tokenStart = i - codeStart = i + 1 + end := strings.IndexByte(markdown[searchStart:], '`') + + if end == -1 { + continue } + r.WriteString(html.EscapeString(markdown[tokenStart:i])) + r.WriteString("") + r.WriteString(html.EscapeString(markdown[searchStart : searchStart+end])) + r.WriteString("") + + searchStart += end + 1 + tokenStart = searchStart + case '*', '_': if i == emStart { strongStart = i + 1 @@ -343,6 +394,34 @@ func (r *renderer) writeText(markdown string) { tokenStart = i emStart = i + 1 } + + case '~': + if i+1 >= len(markdown) || markdown[i+1] != '~' { + continue + } + + if strikeStart != -1 { + r.WriteString("") + r.WriteString(html.EscapeString(markdown[strikeStart:i])) + r.WriteString("") + 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 } } } @@ -380,4 +459,4 @@ func nextPowerOf2(x uint32) uint32 { x |= x >> 16 x++ return x -} +} \ No newline at end of file diff --git a/Render_test.go b/Render_test.go index 33c2be7..86fbe97 100644 --- a/Render_test.go +++ b/Render_test.go @@ -3,8 +3,8 @@ package markdown_test import ( "testing" - "git.akyoto.dev/go/assert" - "git.akyoto.dev/go/markdown" + "git.urbach.dev/go/assert" + "git.urbach.dev/go/markdown" ) func TestEmpty(t *testing.T) { @@ -39,20 +39,44 @@ func TestBold(t *testing.T) { assert.Equal(t, markdown.Render("__bold__"), "

bold

") } +func TestStrike(t *testing.T) { + assert.Equal(t, markdown.Render("~normal text~"), "

~normal text~

") + assert.Equal(t, markdown.Render("~~deleted text~~"), "

deleted text

") +} + 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.

") + 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("[text](https://example.com/_test_)"), `

text

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

Prefix text suffix.

`) +} + +func TestImage(t *testing.T) { + assert.Equal(t, markdown.Render("!"), `

!

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

![

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

![]

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

![](

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

`) + assert.Equal(t, markdown.Render("![title](https://example.com/image.png)"), `

title

`) } func TestList(t *testing.T) { + assert.Equal(t, markdown.Render("-"), "

-

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

-

") +} + +func TestOrderedList(t *testing.T) { + assert.Equal(t, markdown.Render("1"), "

1

") + assert.Equal(t, markdown.Render("1."), "
") + assert.Equal(t, markdown.Render("1. "), "
") + assert.Equal(t, markdown.Render("1. Entry"), "
  1. Entry
") + assert.Equal(t, markdown.Render("999) Entry"), "
  1. Entry
") + assert.Equal(t, markdown.Render("1. Entry\n2. Entry"), "
  1. Entry
  2. Entry
") } func TestTables(t *testing.T) { @@ -64,6 +88,7 @@ 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}
") assert.Equal(t, markdown.Render("`monospace`"), "

monospace

") + assert.Equal(t, markdown.Render("`unfinished"), "

`unfinished

") assert.Equal(t, markdown.Render("Inline `monospace` text."), "

Inline monospace text.

") } @@ -81,22 +106,22 @@ func TestSeparator(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

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

Title

Text.

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

Header

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

Header

") - assert.Equal(t, markdown.Render("> **bold** and *italic* text."), "

bold and italic text.

") - assert.Equal(t, markdown.Render("> __bold__ and _italic_ text."), "

bold and italic text.

") + 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

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

Title

Text.

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

Header

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

Header

`) + assert.Equal(t, markdown.Render("> **bold** and *italic* text."), `

bold and italic text.

`) + assert.Equal(t, markdown.Render("> __bold__ and _italic_ text."), `

bold and italic text.

`) } 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]( 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>

") -} + 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]( 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>

`) +} \ No newline at end of file diff --git a/benchmarks_test.go b/benchmarks_test.go index c9231e9..50501be 100644 --- a/benchmarks_test.go +++ b/benchmarks_test.go @@ -4,8 +4,8 @@ import ( "os" "testing" - "git.akyoto.dev/go/assert" - "git.akyoto.dev/go/markdown" + "git.urbach.dev/go/assert" + "git.urbach.dev/go/markdown" ) func BenchmarkSmall(b *testing.B) { @@ -13,7 +13,7 @@ func BenchmarkSmall(b *testing.B) { assert.Nil(b, err) input := string(small) - for range b.N { + for b.Loop() { markdown.Render(input) } } @@ -23,7 +23,7 @@ func BenchmarkMedium(b *testing.B) { assert.Nil(b, err) input := string(medium) - for range b.N { + for b.Loop() { markdown.Render(input) } } @@ -33,7 +33,7 @@ func BenchmarkLarge(b *testing.B) { assert.Nil(b, err) input := string(large) - for range b.N { + for b.Loop() { markdown.Render(input) } -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index f178769..229a4be 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 9fc2547..7684754 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +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= +git.urbach.dev/go/assert v0.0.0-20250606150337-559d3d3afcda h1:VN6ZQwtwLOm2xTms+v8IIeeNjvs55qyEBNArv3dPq9g= +git.urbach.dev/go/assert v0.0.0-20250606150337-559d3d3afcda/go.mod h1:PNI/NSBOqvoeU58/7eBsIR09Yoq2S/qtSRiTrctkiq0=