diff --git a/README.md b/README.md index ea5cc1b..b0d3057 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,20 @@ A markdown renderer that supports only a subset of the CommonMark spec in order ## Features -- Bold -- Code -- Italic +- Code blocks +- Formatting (bold, italic, monospace) - Links - Lists -- Images - Headers - Paragraphs - Quotes - Separators -- Strikethrough - Tables ## Installation ```shell -go get git.urbach.dev/go/markdown +go get git.akyoto.dev/go/markdown ``` ## Usage @@ -37,9 +34,7 @@ PASS: TestParagraph PASS: TestHeader PASS: TestItalic PASS: TestBold -PASS: TestStrike PASS: TestLink -PASS: TestImage PASS: TestList PASS: TestTables PASS: TestCode @@ -53,14 +48,14 @@ coverage: 100.0% of statements ## Benchmarks ``` -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 +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 ``` ## License -Please see the [license documentation](https://urbach.dev/license). +Please see the [license documentation](https://akyoto.dev/license). ## Copyright diff --git a/Render.go b/Render.go index d9facae..3bc3df4 100644 --- a/Render.go +++ b/Render.go @@ -17,7 +17,6 @@ type renderer struct { paragraphLevel int quoteLevel int listLevel int - olistLevel int tableLevel int codeLines int tableHeaderWritten bool @@ -201,26 +200,6 @@ 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++ @@ -254,12 +233,7 @@ func (r *renderer) closeLists() { r.WriteString("") } - for range r.olistLevel { - r.WriteString("

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

`) + 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 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"), "") -} - -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
") + assert.Equal(t, markdown.Render("- Entry 1\n- Entry 2\n- Entry 3"), "") + assert.Equal(t, markdown.Render("-"), "

-

") } func TestTables(t *testing.T) { @@ -88,7 +64,6 @@ 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.

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

`) -} \ No newline at end of file + 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>

") +} diff --git a/benchmarks_test.go b/benchmarks_test.go index 50501be..c9231e9 100644 --- a/benchmarks_test.go +++ b/benchmarks_test.go @@ -4,8 +4,8 @@ import ( "os" "testing" - "git.urbach.dev/go/assert" - "git.urbach.dev/go/markdown" + "git.akyoto.dev/go/assert" + "git.akyoto.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 b.Loop() { + for range b.N { markdown.Render(input) } } @@ -23,7 +23,7 @@ func BenchmarkMedium(b *testing.B) { assert.Nil(b, err) input := string(medium) - for b.Loop() { + for range b.N { markdown.Render(input) } } @@ -33,7 +33,7 @@ func BenchmarkLarge(b *testing.B) { assert.Nil(b, err) input := string(large) - for b.Loop() { + for range b.N { markdown.Render(input) } -} \ No newline at end of file +} diff --git a/go.mod b/go.mod index 229a4be..f178769 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module git.urbach.dev/go/markdown +module git.akyoto.dev/go/markdown -go 1.24 +go 1.22.1 -require git.urbach.dev/go/assert v0.0.0-20250606150337-559d3d3afcda +require git.akyoto.dev/go/assert v0.1.3 diff --git a/go.sum b/go.sum index 7684754..9fc2547 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -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= +git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8= +git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM=