@peaske7/readit 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/CLAUDE.md +118 -76
- package/.claude/commands/review.md +1 -1
- package/.claude/roadmap.md +32 -9
- package/.claude/user-stories.md +100 -15
- package/AGENTS.md +30 -26
- package/Makefile +32 -0
- package/README.md +90 -2
- package/biome.json +18 -8
- package/bun.lock +426 -568
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +56 -1
- package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
- package/e2e/comments.spec.ts +14 -58
- package/e2e/document-load.spec.ts +1 -23
- package/e2e/export.spec.ts +4 -4
- package/e2e/perf/add-comment.spec.ts +9 -11
- package/e2e/perf/fixtures/generate.ts +1 -5
- package/e2e/perf/screenshot-final.png +0 -0
- package/e2e/perf/utils/metrics.ts +73 -9
- package/e2e/persistence-file.spec.ts +41 -26
- package/e2e/utils/selection.ts +17 -73
- package/go/cmd/readit/main.go +416 -0
- package/go/go.mod +20 -0
- package/go/go.sum +41 -0
- package/go/internal/server/anchor.go +302 -0
- package/go/internal/server/anchor_test.go +111 -0
- package/go/internal/server/comments.go +390 -0
- package/go/internal/server/documents.go +113 -0
- package/go/internal/server/embed.go +17 -0
- package/go/internal/server/headings.go +33 -0
- package/go/internal/server/headings_test.go +75 -0
- package/go/internal/server/htmltext.go +123 -0
- package/go/internal/server/markdown.go +157 -0
- package/go/internal/server/markdown_bench_test.go +42 -0
- package/go/internal/server/markdown_test.go +79 -0
- package/go/internal/server/server.go +453 -0
- package/go/internal/server/server_bench_test.go +122 -0
- package/go/internal/server/settings.go +110 -0
- package/go/internal/server/sse.go +140 -0
- package/go/internal/server/storage.go +275 -0
- package/go/internal/server/storage_test.go +118 -0
- package/go/internal/server/template.go +66 -0
- package/go/internal/server/types.go +101 -0
- package/go/internal/server/watcher.go +74 -0
- package/index.html +4 -14
- package/nvim-readit/lua/readit/health.lua +64 -0
- package/nvim-readit/lua/readit/init.lua +463 -0
- package/nvim-readit/plugin/readit.lua +19 -0
- package/package.json +20 -28
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +881 -0
- package/src/cli.ts +183 -21
- package/src/components/ActionsMenu.svelte +95 -0
- package/src/components/CommentBadge.svelte +67 -0
- package/src/components/CommentErrorBanner.svelte +33 -0
- package/src/components/CommentInput.svelte +75 -0
- package/src/components/CommentListItem.svelte +95 -0
- package/src/components/CommentManager.svelte +129 -0
- package/src/components/CommentNav.svelte +109 -0
- package/src/components/DocumentViewer.svelte +218 -0
- package/src/components/FloatingComment.svelte +107 -0
- package/src/components/Header.svelte +76 -0
- package/src/components/InlineEditor.svelte +72 -0
- package/src/components/MarginNote.svelte +167 -0
- package/src/components/MarginNotesContainer.svelte +33 -0
- package/src/components/RawModal.svelte +126 -0
- package/src/components/ReanchorConfirm.svelte +30 -0
- package/src/components/SettingsModal.svelte +220 -0
- package/src/components/ShortcutCapture.svelte +82 -0
- package/src/components/ShortcutList.svelte +145 -0
- package/src/components/TabBar.svelte +52 -0
- package/src/components/TableOfContents.svelte +125 -0
- package/src/components/ui/ActionLink.svelte +40 -0
- package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
- package/src/components/ui/Dialog.svelte +97 -0
- package/src/components/ui/DropdownMenu.svelte +85 -0
- package/src/components/ui/DropdownMenuItem.svelte +38 -0
- package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
- package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
- package/src/env.d.ts +6 -0
- package/src/index.css +36 -166
- package/src/lib/__fixtures__/bench-data.ts +0 -13
- package/src/lib/anchor.bench.ts +1 -12
- package/src/lib/anchor.test.ts +0 -8
- package/src/lib/anchor.ts +0 -4
- package/src/lib/comment-storage.bench.ts +49 -0
- package/src/lib/comment-storage.test.ts +41 -33
- package/src/lib/comment-storage.ts +21 -18
- package/src/lib/export.bench.ts +21 -0
- package/src/lib/export.ts +0 -1
- package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
- package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
- package/src/lib/highlight/core.test.ts +0 -5
- package/src/lib/highlight/dom.ts +52 -216
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +112 -132
- package/src/lib/highlight/resolver.ts +5 -79
- package/src/lib/highlight/types.ts +0 -5
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +26 -0
- package/src/lib/i18n/ja.ts +26 -0
- package/src/lib/i18n/types.ts +25 -0
- package/src/lib/margin-layout.bench.ts +61 -0
- package/src/lib/margin-layout.ts +0 -7
- package/src/lib/markdown-renderer.test.ts +154 -0
- package/src/lib/markdown-renderer.ts +177 -0
- package/src/lib/mermaid-config.ts +38 -0
- package/src/lib/mermaid-renderer.ts +162 -0
- package/src/lib/mermaid-worker.ts +60 -0
- package/src/lib/positions.ts +31 -24
- package/src/lib/shortcut-registry.ts +244 -0
- package/src/lib/utils.ts +0 -29
- package/src/main.ts +16 -0
- package/src/schema.ts +16 -5
- package/src/server.ts +355 -91
- package/src/stores/app.svelte.ts +231 -0
- package/src/stores/locale.svelte.ts +46 -0
- package/src/stores/settings.svelte.ts +90 -0
- package/src/stores/shortcuts.svelte.ts +104 -0
- package/src/stores/ui.svelte.ts +12 -0
- package/src/template.ts +104 -0
- package/src/test-setup.ts +47 -0
- package/svelte.config.js +5 -0
- package/tsconfig.json +2 -2
- package/vite.config.ts +23 -3
- package/vscode-readit/.mcp.json +7 -0
- package/vscode-readit/.vscodeignore +7 -0
- package/vscode-readit/bun.lock +78 -0
- package/vscode-readit/icon.svg +10 -0
- package/vscode-readit/package.json +110 -0
- package/vscode-readit/src/extension.ts +117 -0
- package/vscode-readit/src/server-manager.ts +272 -0
- package/vscode-readit/src/webview-provider.ts +204 -0
- package/vscode-readit/tsconfig.json +20 -0
- package/e2e/fixtures/sample.html +0 -13
- package/src/App.tsx +0 -368
- package/src/components/ActionsMenu.tsx +0 -91
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
- package/src/components/Header.tsx +0 -54
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -185
- package/src/components/MarginNotes.tsx +0 -23
- package/src/components/RawModal.tsx +0 -144
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -232
- package/src/components/TabBar.tsx +0 -60
- package/src/components/TableOfContents.tsx +0 -108
- package/src/components/comments/CommentBadge.tsx +0 -49
- package/src/components/comments/CommentInput.tsx +0 -86
- package/src/components/comments/CommentListItem.tsx +0 -90
- package/src/components/comments/CommentManager.tsx +0 -129
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionLink.tsx +0 -28
- package/src/components/ui/Dialog.tsx +0 -116
- package/src/components/ui/DropdownMenu.tsx +0 -158
- package/src/contexts/CommentContext.tsx +0 -198
- package/src/contexts/LocaleContext.tsx +0 -76
- package/src/contexts/PositionsContext.tsx +0 -16
- package/src/contexts/SettingsContext.tsx +0 -133
- package/src/hooks/useClickOutside.ts +0 -31
- package/src/hooks/useCommentNavigation.ts +0 -107
- package/src/hooks/useComments.ts +0 -311
- package/src/hooks/useDocument.ts +0 -157
- package/src/hooks/useScrollSpy.ts +0 -77
- package/src/hooks/useTextSelection.ts +0 -86
- package/src/lib/highlight/worker.ts +0 -45
- package/src/main.tsx +0 -13
- package/src/store.ts +0 -222
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"regexp"
|
|
5
|
+
"strconv"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
var (
|
|
10
|
+
htmlTagRe = regexp.MustCompile(`<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*\/?>`)
|
|
11
|
+
entityRe = regexp.MustCompile(`&(?:#(\d+)|#x([0-9a-fA-F]+)|(\w+));`)
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
var blockElements = map[string]bool{
|
|
15
|
+
"p": true, "div": true, "h1": true, "h2": true, "h3": true,
|
|
16
|
+
"h4": true, "h5": true, "h6": true, "pre": true, "blockquote": true,
|
|
17
|
+
"li": true, "tr": true, "br": true,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var namedEntities = map[string]string{
|
|
21
|
+
"amp": "&", "lt": "<", "gt": ">", "quot": `"`,
|
|
22
|
+
"apos": "'", "nbsp": "\u00a0",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func ExtractTextFromHTML(html string) string {
|
|
26
|
+
var b strings.Builder
|
|
27
|
+
lastPos := 0
|
|
28
|
+
prevWasBlock := false
|
|
29
|
+
|
|
30
|
+
for _, loc := range htmlTagRe.FindAllStringIndex(html, -1) {
|
|
31
|
+
text := html[lastPos:loc[0]]
|
|
32
|
+
decoded := decodeEntities(text)
|
|
33
|
+
if decoded != "" {
|
|
34
|
+
if prevWasBlock && b.Len() > 0 {
|
|
35
|
+
b.WriteByte('\n')
|
|
36
|
+
}
|
|
37
|
+
b.WriteString(decoded)
|
|
38
|
+
prevWasBlock = false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check if this tag is a block element
|
|
42
|
+
tag := htmlTagRe.FindStringSubmatch(html[loc[0]:loc[1]])
|
|
43
|
+
if len(tag) > 1 && blockElements[strings.ToLower(tag[1])] {
|
|
44
|
+
prevWasBlock = true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
lastPos = loc[1]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Trailing text
|
|
51
|
+
if lastPos < len(html) {
|
|
52
|
+
text := html[lastPos:]
|
|
53
|
+
decoded := decodeEntities(text)
|
|
54
|
+
if decoded != "" {
|
|
55
|
+
if prevWasBlock && b.Len() > 0 {
|
|
56
|
+
b.WriteByte('\n')
|
|
57
|
+
}
|
|
58
|
+
b.WriteString(decoded)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return b.String()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func decodeEntities(s string) string {
|
|
66
|
+
return entityRe.ReplaceAllStringFunc(s, func(m string) string {
|
|
67
|
+
sub := entityRe.FindStringSubmatch(m)
|
|
68
|
+
if sub[1] != "" { // &#NNN;
|
|
69
|
+
if code, err := strconv.Atoi(sub[1]); err == nil {
|
|
70
|
+
return string(rune(code))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if sub[2] != "" { // &#xHHH;
|
|
74
|
+
if code, err := strconv.ParseInt(sub[2], 16, 32); err == nil {
|
|
75
|
+
return string(rune(code))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if sub[3] != "" { // &name;
|
|
79
|
+
if r, ok := namedEntities[sub[3]]; ok {
|
|
80
|
+
return r
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return m
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func FindTextPosition(textContent, selectedText string, hintOffset int) (start, end int, ok bool) {
|
|
88
|
+
if selectedText == "" {
|
|
89
|
+
return 0, 0, false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
var positions []int
|
|
93
|
+
searchFrom := 0
|
|
94
|
+
for {
|
|
95
|
+
idx := strings.Index(textContent[searchFrom:], selectedText)
|
|
96
|
+
if idx < 0 {
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
positions = append(positions, searchFrom+idx)
|
|
100
|
+
searchFrom += idx + 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if len(positions) == 0 {
|
|
104
|
+
return 0, 0, false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if len(positions) == 1 {
|
|
108
|
+
return positions[0], positions[0] + len(selectedText), true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Multiple matches - pick closest to hint
|
|
112
|
+
best := positions[0]
|
|
113
|
+
bestDist := abs(best - hintOffset)
|
|
114
|
+
for _, pos := range positions[1:] {
|
|
115
|
+
d := abs(pos - hintOffset)
|
|
116
|
+
if d < bestDist {
|
|
117
|
+
best = pos
|
|
118
|
+
bestDist = d
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return best, best + len(selectedText), true
|
|
123
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"fmt"
|
|
6
|
+
htmlpkg "html"
|
|
7
|
+
"regexp"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
|
11
|
+
"github.com/microcosm-cc/bluemonday"
|
|
12
|
+
"github.com/yuin/goldmark"
|
|
13
|
+
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
|
14
|
+
"github.com/yuin/goldmark/ast"
|
|
15
|
+
"github.com/yuin/goldmark/extension"
|
|
16
|
+
"github.com/yuin/goldmark/parser"
|
|
17
|
+
"github.com/yuin/goldmark/renderer/html"
|
|
18
|
+
"github.com/yuin/goldmark/text"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
type RenderResult struct {
|
|
22
|
+
HTML string
|
|
23
|
+
Headings []Heading
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Renderer struct {
|
|
27
|
+
md goldmark.Markdown
|
|
28
|
+
sanitize *bluemonday.Policy
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func NewRenderer() *Renderer {
|
|
32
|
+
// Allow raw HTML through goldmark, but sanitize to strip dangerous elements.
|
|
33
|
+
p := bluemonday.UGCPolicy()
|
|
34
|
+
p.AllowAttrs("class").Globally()
|
|
35
|
+
p.AllowAttrs("id").Globally()
|
|
36
|
+
p.AllowAttrs("style").Globally()
|
|
37
|
+
p.AllowElements("details", "summary", "pre", "code", "span")
|
|
38
|
+
p.AllowAttrs("open").OnElements("details")
|
|
39
|
+
p.AllowAttrs("data-mermaid-placeholder").OnElements("div")
|
|
40
|
+
|
|
41
|
+
return &Renderer{
|
|
42
|
+
md: goldmark.New(
|
|
43
|
+
goldmark.WithExtensions(
|
|
44
|
+
extension.GFM,
|
|
45
|
+
extension.TaskList,
|
|
46
|
+
highlighting.NewHighlighting(
|
|
47
|
+
highlighting.WithStyle("onedark"),
|
|
48
|
+
highlighting.WithGuessLanguage(true),
|
|
49
|
+
highlighting.WithFormatOptions(
|
|
50
|
+
chromahtml.WithClasses(true),
|
|
51
|
+
chromahtml.WithLineNumbers(false),
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
goldmark.WithParserOptions(
|
|
56
|
+
parser.WithAutoHeadingID(),
|
|
57
|
+
),
|
|
58
|
+
goldmark.WithRendererOptions(
|
|
59
|
+
html.WithUnsafe(),
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
sanitize: p,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
var mermaidFenceRe = regexp.MustCompile("(?m)^```mermaid\\s*\n([\\s\\S]*?)^```\\s*$")
|
|
67
|
+
|
|
68
|
+
// Render converts markdown source to HTML and extracts headings.
|
|
69
|
+
// Mermaid fenced blocks are extracted before rendering so chroma doesn't
|
|
70
|
+
// consume them, then re-injected as <pre><code class="language-mermaid">.
|
|
71
|
+
func (r *Renderer) Render(source []byte) (RenderResult, error) {
|
|
72
|
+
content := string(source)
|
|
73
|
+
|
|
74
|
+
var mermaidBlocks []string
|
|
75
|
+
processed := mermaidFenceRe.ReplaceAllStringFunc(content, func(match string) string {
|
|
76
|
+
sub := mermaidFenceRe.FindStringSubmatch(match)
|
|
77
|
+
code := strings.TrimSpace(sub[1])
|
|
78
|
+
idx := len(mermaidBlocks)
|
|
79
|
+
mermaidBlocks = append(mermaidBlocks, code)
|
|
80
|
+
return fmt.Sprintf(`<div data-mermaid-placeholder="%d"></div>`, idx)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
processedBytes := []byte(processed)
|
|
84
|
+
|
|
85
|
+
// Parse into AST so we can extract headings with correct IDs
|
|
86
|
+
reader := text.NewReader(processedBytes)
|
|
87
|
+
doc := r.md.Parser().Parse(reader)
|
|
88
|
+
|
|
89
|
+
// Extract headings from the AST (IDs match rendered HTML exactly)
|
|
90
|
+
headings := extractHeadingsFromAST(doc, processedBytes)
|
|
91
|
+
|
|
92
|
+
// Render the AST to HTML
|
|
93
|
+
var buf bytes.Buffer
|
|
94
|
+
if err := r.md.Renderer().Render(&buf, processedBytes, doc); err != nil {
|
|
95
|
+
return RenderResult{}, fmt.Errorf("goldmark render: %w", err)
|
|
96
|
+
}
|
|
97
|
+
output := buf.String()
|
|
98
|
+
|
|
99
|
+
output = r.sanitize.Sanitize(output)
|
|
100
|
+
|
|
101
|
+
// Restore mermaid blocks (after sanitization since they use escaped content)
|
|
102
|
+
for i, code := range mermaidBlocks {
|
|
103
|
+
placeholder := fmt.Sprintf(`<div data-mermaid-placeholder="%d"></div>`, i)
|
|
104
|
+
replacement := fmt.Sprintf(`<pre><code class="language-mermaid">%s</code></pre>`, htmlpkg.EscapeString(code))
|
|
105
|
+
output = strings.Replace(output, placeholder, replacement, 1)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return RenderResult{HTML: output, Headings: headings}, nil
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// extractHeadingsFromAST walks the goldmark AST to collect heading nodes.
|
|
112
|
+
// This produces IDs that match the rendered HTML exactly (via WithAutoHeadingID).
|
|
113
|
+
func extractHeadingsFromAST(doc ast.Node, source []byte) []Heading {
|
|
114
|
+
var headings []Heading
|
|
115
|
+
|
|
116
|
+
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
117
|
+
if !entering || n.Kind() != ast.KindHeading {
|
|
118
|
+
return ast.WalkContinue, nil
|
|
119
|
+
}
|
|
120
|
+
h := n.(*ast.Heading)
|
|
121
|
+
|
|
122
|
+
var id string
|
|
123
|
+
if raw, ok := h.AttributeString("id"); ok {
|
|
124
|
+
if b, isByte := raw.([]byte); isByte {
|
|
125
|
+
id = string(b)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Collect plain text from all inline children recursively
|
|
130
|
+
var textBuf bytes.Buffer
|
|
131
|
+
collectText(h, source, &textBuf)
|
|
132
|
+
|
|
133
|
+
headings = append(headings, Heading{
|
|
134
|
+
ID: id,
|
|
135
|
+
Text: strings.TrimSpace(textBuf.String()),
|
|
136
|
+
Level: h.Level,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
return ast.WalkSkipChildren, nil
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return headings
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// collectText recursively collects plain text from AST nodes.
|
|
146
|
+
func collectText(n ast.Node, source []byte, buf *bytes.Buffer) {
|
|
147
|
+
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
|
148
|
+
if t, ok := c.(*ast.Text); ok {
|
|
149
|
+
buf.Write(t.Segment.Value(source))
|
|
150
|
+
if t.SoftLineBreak() {
|
|
151
|
+
buf.WriteByte(' ')
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
collectText(c, source, buf)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func generateMarkdown(lines int) []byte {
|
|
10
|
+
var b strings.Builder
|
|
11
|
+
b.WriteString("# Document Title\n\n")
|
|
12
|
+
for i := range lines {
|
|
13
|
+
switch i % 10 {
|
|
14
|
+
case 0:
|
|
15
|
+
fmt.Fprintf(&b, "## Section %d\n\n", i/10)
|
|
16
|
+
case 5:
|
|
17
|
+
b.WriteString("```go\nfunc hello() {\n\tfmt.Println(\"world\")\n}\n```\n\n")
|
|
18
|
+
default:
|
|
19
|
+
fmt.Fprintf(&b, "This is paragraph %d with some **bold** and *italic* text. ", i)
|
|
20
|
+
b.WriteString("It contains [links](https://example.com) and `inline code`.\n\n")
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return []byte(b.String())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func BenchmarkRender1000Lines(b *testing.B) {
|
|
27
|
+
r := NewRenderer()
|
|
28
|
+
src := generateMarkdown(1000)
|
|
29
|
+
b.ResetTimer()
|
|
30
|
+
for i := 0; i < b.N; i++ {
|
|
31
|
+
r.Render(src)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func BenchmarkRender3000Lines(b *testing.B) {
|
|
36
|
+
r := NewRenderer()
|
|
37
|
+
src := generateMarkdown(3000)
|
|
38
|
+
b.ResetTimer()
|
|
39
|
+
for i := 0; i < b.N; i++ {
|
|
40
|
+
r.Render(src)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestRenderBasicMarkdown(t *testing.T) {
|
|
9
|
+
r := NewRenderer()
|
|
10
|
+
result, err := r.Render([]byte("# Hello\n\nSome **bold** text."))
|
|
11
|
+
if err != nil {
|
|
12
|
+
t.Fatal(err)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if !strings.Contains(result.HTML, "<h1") {
|
|
16
|
+
t.Error("expected <h1> in output")
|
|
17
|
+
}
|
|
18
|
+
if !strings.Contains(result.HTML, "<strong>bold</strong>") {
|
|
19
|
+
t.Error("expected <strong> in output")
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestRenderCodeBlock(t *testing.T) {
|
|
24
|
+
r := NewRenderer()
|
|
25
|
+
src := "```go\nfmt.Println(\"hello\")\n```"
|
|
26
|
+
result, err := r.Render([]byte(src))
|
|
27
|
+
if err != nil {
|
|
28
|
+
t.Fatal(err)
|
|
29
|
+
}
|
|
30
|
+
if !strings.Contains(result.HTML, "chroma") {
|
|
31
|
+
t.Error("expected chroma class in highlighted code")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func TestRenderMermaidPassthrough(t *testing.T) {
|
|
36
|
+
r := NewRenderer()
|
|
37
|
+
src := "```mermaid\ngraph TD\n A --> B\n```"
|
|
38
|
+
result, err := r.Render([]byte(src))
|
|
39
|
+
if err != nil {
|
|
40
|
+
t.Fatal(err)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Mermaid blocks should pass through as code blocks for client-side rendering
|
|
44
|
+
if !strings.Contains(result.HTML, "language-mermaid") {
|
|
45
|
+
t.Error("expected language-mermaid class in output")
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func TestRenderGFMTable(t *testing.T) {
|
|
50
|
+
r := NewRenderer()
|
|
51
|
+
src := "| A | B |\n|---|---|\n| 1 | 2 |"
|
|
52
|
+
result, err := r.Render([]byte(src))
|
|
53
|
+
if err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if !strings.Contains(result.HTML, "<table>") {
|
|
58
|
+
t.Error("expected <table> in GFM table output")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestRenderHeadings(t *testing.T) {
|
|
63
|
+
r := NewRenderer()
|
|
64
|
+
result, err := r.Render([]byte("# Title\n\n## Section\n\n### Sub"))
|
|
65
|
+
if err != nil {
|
|
66
|
+
t.Fatal(err)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if len(result.Headings) != 3 {
|
|
70
|
+
t.Fatalf("expected 3 headings, got %d", len(result.Headings))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if result.Headings[0].Level != 1 || result.Headings[0].Text != "Title" {
|
|
74
|
+
t.Errorf("heading 0: got level=%d text=%q", result.Headings[0].Level, result.Headings[0].Text)
|
|
75
|
+
}
|
|
76
|
+
if result.Headings[1].Level != 2 || result.Headings[1].Text != "Section" {
|
|
77
|
+
t.Errorf("heading 1: got level=%d text=%q", result.Headings[1].Level, result.Headings[1].Text)
|
|
78
|
+
}
|
|
79
|
+
}
|