@peaske7/readit 0.1.8 → 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 -5
- package/biome.json +18 -8
- package/bun.lock +426 -710
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +130 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- 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 +116 -0
- package/e2e/perf/fixtures/generate.ts +327 -0
- package/e2e/perf/initial-load.spec.ts +49 -0
- package/e2e/perf/perf.setup.ts +23 -0
- package/e2e/perf/perf.teardown.ts +9 -0
- package/e2e/perf/screenshot-final.png +0 -0
- package/e2e/perf/scroll.spec.ts +39 -0
- package/e2e/perf/tab-switch.spec.ts +69 -0
- package/e2e/perf/text-selection.spec.ts +119 -0
- package/e2e/perf/utils/metrics.ts +350 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- 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 +24 -41
- package/playwright.config.ts +12 -0
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +881 -0
- package/src/{cli/index.ts → cli.ts} +216 -70
- 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.svelte +53 -0
- 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.svelte +42 -0
- package/src/env.d.ts +6 -0
- package/src/index.css +36 -166
- package/src/lib/__fixtures__/bench-data.ts +1 -54
- package/src/lib/anchor.bench.ts +47 -68
- package/src/lib/anchor.test.ts +5 -9
- package/src/lib/anchor.ts +9 -93
- package/src/lib/comment-storage.bench.ts +6 -20
- package/src/lib/comment-storage.test.ts +45 -37
- package/src/lib/comment-storage.ts +23 -64
- package/src/lib/export.bench.ts +9 -23
- package/src/lib/export.ts +7 -14
- package/src/lib/headings.test.ts +103 -0
- package/src/lib/headings.ts +44 -0
- package/src/lib/highlight/core.test.ts +1 -6
- package/src/lib/highlight/dom.ts +53 -280
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +122 -302
- package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
- package/src/lib/highlight/types.ts +0 -40
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +13 -36
- package/src/lib/i18n/ja.ts +14 -37
- package/src/lib/i18n/types.ts +13 -36
- package/src/lib/margin-layout.bench.ts +48 -15
- package/src/lib/margin-layout.ts +2 -31
- 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 +157 -0
- package/src/lib/shortcut-registry.ts +138 -103
- package/src/lib/utils.ts +2 -48
- package/src/main.ts +16 -0
- package/src/schema.ts +92 -0
- package/src/{server/index.ts → server.ts} +427 -163
- 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 +31 -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 -416
- package/src/components/ActionsMenu.tsx +0 -112
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/Header.tsx +0 -65
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -207
- package/src/components/MarginNotes.tsx +0 -50
- package/src/components/RawModal.tsx +0 -143
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -310
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- 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 -114
- package/src/components/comments/CommentListItem.tsx +0 -92
- package/src/components/comments/CommentManager.tsx +0 -113
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/ActionLink.tsx +0 -32
- package/src/components/ui/Button.tsx +0 -55
- package/src/components/ui/Dialog.tsx +0 -156
- package/src/components/ui/DropdownMenu.tsx +0 -114
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/components/ui/Text.tsx +0 -54
- package/src/contexts/CommentContext.tsx +0 -229
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/contexts/LocaleContext.tsx +0 -35
- package/src/hooks/useClickOutside.ts +0 -35
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useCommentNavigation.ts +0 -130
- package/src/hooks/useComments.ts +0 -323
- package/src/hooks/useDocument.ts +0 -156
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- package/src/hooks/useHeadings.test.ts +0 -159
- package/src/hooks/useHeadings.ts +0 -129
- package/src/hooks/useKeybindings.ts +0 -108
- package/src/hooks/useKeyboardShortcuts.ts +0 -63
- package/src/hooks/useLayoutMode.ts +0 -44
- package/src/hooks/useLocalePreference.ts +0 -42
- package/src/hooks/useReanchorMode.ts +0 -33
- package/src/hooks/useScrollMetrics.ts +0 -56
- package/src/hooks/useScrollSpy.ts +0 -81
- package/src/hooks/useTextSelection.ts +0 -123
- package/src/hooks/useThemePreference.ts +0 -66
- package/src/lib/context.bench.ts +0 -41
- package/src/lib/context.test.ts +0 -224
- package/src/lib/context.ts +0 -193
- package/src/lib/editor-links.ts +0 -59
- package/src/lib/highlight/colors.ts +0 -37
- package/src/lib/highlight/index.ts +0 -23
- package/src/lib/highlight/script-builder.ts +0 -485
- package/src/lib/html-processor.test.tsx +0 -170
- package/src/lib/html-processor.tsx +0 -95
- package/src/lib/i18n/completeness.test.ts +0 -51
- package/src/lib/i18n/translations.test.ts +0 -39
- package/src/lib/layout-constants.ts +0 -12
- package/src/lib/scroll.test.ts +0 -118
- package/src/lib/scroll.ts +0 -47
- package/src/lib/shortcut-registry.test.ts +0 -173
- package/src/lib/utils.test.ts +0 -110
- package/src/main.tsx +0 -13
- package/src/store/index.test.ts +0 -242
- package/src/store/index.ts +0 -254
- package/src/types/index.ts +0 -127
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net/http"
|
|
6
|
+
"sync"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// SSEBroker manages Server-Sent Event connections for document updates and heartbeat.
|
|
11
|
+
type SSEBroker struct {
|
|
12
|
+
docClients map[chan string]struct{}
|
|
13
|
+
heartbeatClients map[chan string]struct{}
|
|
14
|
+
mu sync.Mutex
|
|
15
|
+
shutdownTimer *time.Timer
|
|
16
|
+
shutdownEpoch int
|
|
17
|
+
isDev bool
|
|
18
|
+
onShutdown func()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func NewSSEBroker(isDev bool, onShutdown func()) *SSEBroker {
|
|
22
|
+
return &SSEBroker{
|
|
23
|
+
docClients: make(map[chan string]struct{}),
|
|
24
|
+
heartbeatClients: make(map[chan string]struct{}),
|
|
25
|
+
isDev: isDev,
|
|
26
|
+
onShutdown: onShutdown,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (b *SSEBroker) Broadcast(event string) {
|
|
31
|
+
b.mu.Lock()
|
|
32
|
+
defer b.mu.Unlock()
|
|
33
|
+
for ch := range b.docClients {
|
|
34
|
+
select {
|
|
35
|
+
case ch <- event:
|
|
36
|
+
default:
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (b *SSEBroker) DocumentStream(w http.ResponseWriter, r *http.Request) {
|
|
42
|
+
flusher, ok := w.(http.Flusher)
|
|
43
|
+
if !ok {
|
|
44
|
+
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
w.Header().Set("Content-Type", "text/event-stream")
|
|
49
|
+
w.Header().Set("Cache-Control", "no-cache")
|
|
50
|
+
w.Header().Set("Connection", "keep-alive")
|
|
51
|
+
|
|
52
|
+
ch := make(chan string, 16)
|
|
53
|
+
b.mu.Lock()
|
|
54
|
+
b.docClients[ch] = struct{}{}
|
|
55
|
+
b.mu.Unlock()
|
|
56
|
+
|
|
57
|
+
defer func() {
|
|
58
|
+
b.mu.Lock()
|
|
59
|
+
delete(b.docClients, ch)
|
|
60
|
+
b.mu.Unlock()
|
|
61
|
+
}()
|
|
62
|
+
|
|
63
|
+
_, _ = fmt.Fprint(w, ": connected\n\n")
|
|
64
|
+
flusher.Flush()
|
|
65
|
+
|
|
66
|
+
ticker := time.NewTicker(5 * time.Second)
|
|
67
|
+
defer ticker.Stop()
|
|
68
|
+
|
|
69
|
+
for {
|
|
70
|
+
select {
|
|
71
|
+
case <-r.Context().Done():
|
|
72
|
+
return
|
|
73
|
+
case msg := <-ch:
|
|
74
|
+
_, _ = fmt.Fprintf(w, "data: %s\n\n", msg)
|
|
75
|
+
flusher.Flush()
|
|
76
|
+
case <-ticker.C:
|
|
77
|
+
_, _ = fmt.Fprint(w, ": ping\n\n")
|
|
78
|
+
flusher.Flush()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func (b *SSEBroker) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
|
84
|
+
flusher, ok := w.(http.Flusher)
|
|
85
|
+
if !ok {
|
|
86
|
+
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
w.Header().Set("Content-Type", "text/event-stream")
|
|
91
|
+
w.Header().Set("Cache-Control", "no-cache")
|
|
92
|
+
w.Header().Set("Connection", "keep-alive")
|
|
93
|
+
|
|
94
|
+
ch := make(chan string, 16)
|
|
95
|
+
b.mu.Lock()
|
|
96
|
+
b.heartbeatClients[ch] = struct{}{}
|
|
97
|
+
if b.shutdownTimer != nil {
|
|
98
|
+
b.shutdownTimer.Stop()
|
|
99
|
+
b.shutdownTimer = nil
|
|
100
|
+
}
|
|
101
|
+
b.shutdownEpoch++
|
|
102
|
+
b.mu.Unlock()
|
|
103
|
+
|
|
104
|
+
defer func() {
|
|
105
|
+
b.mu.Lock()
|
|
106
|
+
delete(b.heartbeatClients, ch)
|
|
107
|
+
if len(b.heartbeatClients) == 0 && !b.isDev && b.onShutdown != nil {
|
|
108
|
+
if b.shutdownTimer != nil {
|
|
109
|
+
b.shutdownTimer.Stop()
|
|
110
|
+
}
|
|
111
|
+
b.shutdownEpoch++
|
|
112
|
+
epoch := b.shutdownEpoch
|
|
113
|
+
b.shutdownTimer = time.AfterFunc(1500*time.Millisecond, func() {
|
|
114
|
+
b.mu.Lock()
|
|
115
|
+
shouldShutdown := b.shutdownEpoch == epoch
|
|
116
|
+
b.mu.Unlock()
|
|
117
|
+
if shouldShutdown {
|
|
118
|
+
b.onShutdown()
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
b.mu.Unlock()
|
|
123
|
+
}()
|
|
124
|
+
|
|
125
|
+
_, _ = fmt.Fprint(w, ": connected\n\n")
|
|
126
|
+
flusher.Flush()
|
|
127
|
+
|
|
128
|
+
ticker := time.NewTicker(5 * time.Second)
|
|
129
|
+
defer ticker.Stop()
|
|
130
|
+
|
|
131
|
+
for {
|
|
132
|
+
select {
|
|
133
|
+
case <-r.Context().Done():
|
|
134
|
+
return
|
|
135
|
+
case <-ticker.C:
|
|
136
|
+
_, _ = fmt.Fprint(w, ": ping\n\n")
|
|
137
|
+
flusher.Flush()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/rand"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/base64"
|
|
7
|
+
"fmt"
|
|
8
|
+
mrand "math/rand"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"regexp"
|
|
12
|
+
"runtime"
|
|
13
|
+
"strings"
|
|
14
|
+
"time"
|
|
15
|
+
"unicode/utf8"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
var (
|
|
19
|
+
frontMatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---`)
|
|
20
|
+
frontMatterStripRe = regexp.MustCompile(`(?s)^---\n.*?\n---\n*`)
|
|
21
|
+
commentMetaRe = regexp.MustCompile(`<!--\s*c:([^|]+)\|([^|]+)\|([^>]+)\s*-->`)
|
|
22
|
+
anchorPrefixRe = regexp.MustCompile(`<!--\s*anchor:([A-Za-z0-9+/=]+)\s*-->`)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
func CommentPath(filePath string) (string, error) {
|
|
26
|
+
home, err := os.UserHomeDir()
|
|
27
|
+
if err != nil {
|
|
28
|
+
return "", fmt.Errorf("cannot determine home directory: %w", err)
|
|
29
|
+
}
|
|
30
|
+
abs, err := filepath.Abs(filePath)
|
|
31
|
+
if err != nil {
|
|
32
|
+
return "", fmt.Errorf("cannot resolve absolute path for %s: %w", filePath, err)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stripped := abs
|
|
36
|
+
if runtime.GOOS == "windows" {
|
|
37
|
+
if len(stripped) >= 2 && stripped[1] == ':' {
|
|
38
|
+
stripped = stripped[2:]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
stripped = strings.TrimPrefix(stripped, "/")
|
|
42
|
+
|
|
43
|
+
ext := filepath.Ext(stripped)
|
|
44
|
+
if ext != "" {
|
|
45
|
+
stripped = stripped[:len(stripped)-len(ext)]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return filepath.Join(home, ".readit", "comments", stripped+".comments.md"), nil
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func ComputeHash(content []byte) string {
|
|
52
|
+
h := sha256.Sum256(content)
|
|
53
|
+
return fmt.Sprintf("%x", h[:])[:HashLength]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func ParseCommentFile(data []byte) (CommentFile, error) {
|
|
57
|
+
content := string(data)
|
|
58
|
+
cf := CommentFile{Version: FormatVersion}
|
|
59
|
+
|
|
60
|
+
if fm := frontMatterRe.FindStringSubmatch(content); len(fm) > 1 {
|
|
61
|
+
for line := range strings.SplitSeq(fm[1], "\n") {
|
|
62
|
+
line = strings.TrimSpace(line)
|
|
63
|
+
if k, v, ok := strings.Cut(line, ":"); ok {
|
|
64
|
+
v = strings.TrimSpace(v)
|
|
65
|
+
switch strings.TrimSpace(k) {
|
|
66
|
+
case "source":
|
|
67
|
+
cf.Source = v
|
|
68
|
+
case "hash":
|
|
69
|
+
cf.Hash = v
|
|
70
|
+
case "version":
|
|
71
|
+
_, _ = fmt.Sscanf(v, "%d", &cf.Version)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
body := frontMatterStripRe.ReplaceAllString(content, "")
|
|
78
|
+
body = strings.TrimSpace(body)
|
|
79
|
+
if body == "" {
|
|
80
|
+
return cf, nil
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
markers := commentMetaRe.FindAllStringIndex(body, -1)
|
|
84
|
+
for i, loc := range markers {
|
|
85
|
+
var block string
|
|
86
|
+
if i+1 < len(markers) {
|
|
87
|
+
block = body[loc[0]:markers[i+1][0]]
|
|
88
|
+
} else {
|
|
89
|
+
block = body[loc[0]:]
|
|
90
|
+
}
|
|
91
|
+
block = strings.TrimSpace(block)
|
|
92
|
+
if block == "" {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if c, ok := parseCommentBlock(block); ok {
|
|
96
|
+
cf.Comments = append(cf.Comments, c)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return cf, nil
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func parseCommentBlock(block string) (Comment, bool) {
|
|
104
|
+
meta := commentMetaRe.FindStringSubmatch(block)
|
|
105
|
+
if meta == nil {
|
|
106
|
+
return Comment{}, false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
c := Comment{
|
|
110
|
+
ID: strings.TrimSpace(meta[1]),
|
|
111
|
+
LineHint: strings.TrimSpace(meta[2]),
|
|
112
|
+
CreatedAt: strings.TrimSpace(meta[3]),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Optional anchor prefix (base64-encoded)
|
|
116
|
+
if ap := anchorPrefixRe.FindStringSubmatch(block); len(ap) > 1 {
|
|
117
|
+
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(ap[1])); err == nil {
|
|
118
|
+
c.AnchorPrefix = string(decoded)
|
|
119
|
+
} else {
|
|
120
|
+
// Fallback: treat as raw text for backward compatibility
|
|
121
|
+
c.AnchorPrefix = strings.TrimSpace(ap[1])
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
bodyLines := strings.Split(block, "\n")
|
|
126
|
+
pastMeta := false
|
|
127
|
+
inLeadingBlockquote := false
|
|
128
|
+
pastBlockquote := false
|
|
129
|
+
var selectedLines []string
|
|
130
|
+
var commentLines []string
|
|
131
|
+
|
|
132
|
+
for _, line := range bodyLines {
|
|
133
|
+
if commentMetaRe.MatchString(line) || anchorPrefixRe.MatchString(line) {
|
|
134
|
+
pastMeta = true
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if !pastBlockquote && pastMeta && (strings.HasPrefix(line, "> ") || line == ">") {
|
|
138
|
+
inLeadingBlockquote = true
|
|
139
|
+
// Extract the text after "> "
|
|
140
|
+
if strings.HasPrefix(line, "> ") {
|
|
141
|
+
selectedLines = append(selectedLines, line[2:])
|
|
142
|
+
} else {
|
|
143
|
+
selectedLines = append(selectedLines, "")
|
|
144
|
+
}
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if inLeadingBlockquote && !pastBlockquote {
|
|
148
|
+
pastBlockquote = true
|
|
149
|
+
if strings.TrimSpace(line) == "" {
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if pastBlockquote || (pastMeta && !inLeadingBlockquote) {
|
|
154
|
+
pastBlockquote = true
|
|
155
|
+
commentLines = append(commentLines, line)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if len(selectedLines) > 0 {
|
|
160
|
+
c.SelectedText = strings.Join(selectedLines, "\n")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
comment := strings.TrimSpace(strings.Join(commentLines, "\n"))
|
|
164
|
+
comment = strings.TrimRight(comment, "\n")
|
|
165
|
+
if strings.HasSuffix(comment, "\n---") {
|
|
166
|
+
comment = strings.TrimSpace(comment[:len(comment)-4])
|
|
167
|
+
} else if comment == "---" {
|
|
168
|
+
comment = ""
|
|
169
|
+
}
|
|
170
|
+
c.Comment = comment
|
|
171
|
+
|
|
172
|
+
return c, true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func SerializeComments(cf CommentFile) []byte {
|
|
176
|
+
var b strings.Builder
|
|
177
|
+
|
|
178
|
+
b.WriteString("---\n")
|
|
179
|
+
b.WriteString("source: " + cf.Source + "\n")
|
|
180
|
+
b.WriteString("hash: " + cf.Hash + "\n")
|
|
181
|
+
fmt.Fprintf(&b, "version: %d\n", cf.Version)
|
|
182
|
+
b.WriteString("---\n\n")
|
|
183
|
+
|
|
184
|
+
for i, c := range cf.Comments {
|
|
185
|
+
fmt.Fprintf(&b, "<!-- c:%s|%s|%s -->\n", c.ID, c.LineHint, c.CreatedAt)
|
|
186
|
+
|
|
187
|
+
if c.AnchorPrefix != "" {
|
|
188
|
+
encoded := base64.StdEncoding.EncodeToString([]byte(c.AnchorPrefix))
|
|
189
|
+
fmt.Fprintf(&b, "<!-- anchor:%s -->\n", encoded)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for line := range strings.SplitSeq(c.SelectedText, "\n") {
|
|
193
|
+
b.WriteString("> " + line + "\n")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
b.WriteString("\n")
|
|
197
|
+
b.WriteString(c.Comment)
|
|
198
|
+
b.WriteString("\n")
|
|
199
|
+
|
|
200
|
+
if i < len(cf.Comments)-1 {
|
|
201
|
+
b.WriteString("\n---\n\n")
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return []byte(b.String())
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func WriteCommentFile(path string, cf CommentFile) error {
|
|
209
|
+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
210
|
+
return err
|
|
211
|
+
}
|
|
212
|
+
tmp := path + ".tmp"
|
|
213
|
+
if err := os.WriteFile(tmp, SerializeComments(cf), 0644); err != nil {
|
|
214
|
+
return err
|
|
215
|
+
}
|
|
216
|
+
return os.Rename(tmp, path)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func TruncateSelection(text string) string {
|
|
220
|
+
if utf8.RuneCountInString(text) <= MaxSelectionLength {
|
|
221
|
+
return text
|
|
222
|
+
}
|
|
223
|
+
runes := []rune(text)
|
|
224
|
+
half := (MaxSelectionLength - utf8.RuneCountInString(TruncationMarker)) / 2
|
|
225
|
+
return string(runes[:half]) + TruncationMarker + string(runes[len(runes)-half:])
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func GetLineNumber(content string, offset int) int {
|
|
229
|
+
if offset <= 0 {
|
|
230
|
+
return 1
|
|
231
|
+
}
|
|
232
|
+
if offset > len(content) {
|
|
233
|
+
offset = len(content)
|
|
234
|
+
}
|
|
235
|
+
return strings.Count(content[:offset], "\n") + 1
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func GetLineHint(content string, startOffset, endOffset int) string {
|
|
239
|
+
startLine := GetLineNumber(content, startOffset)
|
|
240
|
+
endLine := GetLineNumber(content, endOffset)
|
|
241
|
+
if startLine == endLine {
|
|
242
|
+
return fmt.Sprintf("L%d", startLine)
|
|
243
|
+
}
|
|
244
|
+
return fmt.Sprintf("L%d-L%d", startLine, endLine)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
func NewCommentID() string {
|
|
248
|
+
b := make([]byte, 4)
|
|
249
|
+
if _, err := rand.Read(b); err != nil {
|
|
250
|
+
// Fallback to pseudo-random if crypto/rand fails
|
|
251
|
+
fallback := mrand.New(mrand.NewSource(time.Now().UnixNano()))
|
|
252
|
+
for i := range b {
|
|
253
|
+
b[i] = byte(fallback.Intn(256))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return fmt.Sprintf("%x", b)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
func CreateComment(selectedText, commentText string, startOffset, endOffset int, sourceContent string) Comment {
|
|
260
|
+
truncated := TruncateSelection(selectedText)
|
|
261
|
+
c := Comment{
|
|
262
|
+
ID: NewCommentID(),
|
|
263
|
+
SelectedText: truncated,
|
|
264
|
+
Comment: strings.TrimSpace(commentText),
|
|
265
|
+
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
266
|
+
StartOffset: startOffset,
|
|
267
|
+
EndOffset: endOffset,
|
|
268
|
+
LineHint: GetLineHint(sourceContent, startOffset, endOffset),
|
|
269
|
+
}
|
|
270
|
+
if utf8.RuneCountInString(selectedText) > MaxSelectionLength {
|
|
271
|
+
runes := []rune(selectedText)
|
|
272
|
+
c.AnchorPrefix = string(runes[:min(AnchorPrefixLength, len(runes))])
|
|
273
|
+
}
|
|
274
|
+
return c
|
|
275
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestComputeHash(t *testing.T) {
|
|
6
|
+
hash := ComputeHash([]byte("hello world"))
|
|
7
|
+
if len(hash) != HashLength {
|
|
8
|
+
t.Errorf("expected hash length %d, got %d", HashLength, len(hash))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
hash2 := ComputeHash([]byte("hello world"))
|
|
12
|
+
if hash != hash2 {
|
|
13
|
+
t.Error("same input should produce same hash")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
hash3 := ComputeHash([]byte("different"))
|
|
17
|
+
if hash == hash3 {
|
|
18
|
+
t.Error("different input should produce different hash")
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func TestTruncateSelection(t *testing.T) {
|
|
23
|
+
short := "short text"
|
|
24
|
+
if TruncateSelection(short) != short {
|
|
25
|
+
t.Error("short text should not be truncated")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
long := make([]byte, 2000)
|
|
29
|
+
for i := range long {
|
|
30
|
+
long[i] = 'a'
|
|
31
|
+
}
|
|
32
|
+
truncated := TruncateSelection(string(long))
|
|
33
|
+
if len(truncated) > MaxSelectionLength+10 {
|
|
34
|
+
t.Errorf("truncated length %d exceeds max %d", len(truncated), MaxSelectionLength)
|
|
35
|
+
}
|
|
36
|
+
if truncated[0] != 'a' {
|
|
37
|
+
t.Error("truncated should start with original content")
|
|
38
|
+
}
|
|
39
|
+
if truncated[len(truncated)-1] != 'a' {
|
|
40
|
+
t.Error("truncated should end with original content")
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestGetLineNumber(t *testing.T) {
|
|
45
|
+
content := "line1\nline2\nline3"
|
|
46
|
+
if n := GetLineNumber(content, 0); n != 1 {
|
|
47
|
+
t.Errorf("offset 0: got line %d, want 1", n)
|
|
48
|
+
}
|
|
49
|
+
if n := GetLineNumber(content, 6); n != 2 {
|
|
50
|
+
t.Errorf("offset 6: got line %d, want 2", n)
|
|
51
|
+
}
|
|
52
|
+
if n := GetLineNumber(content, 12); n != 3 {
|
|
53
|
+
t.Errorf("offset 12: got line %d, want 3", n)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func TestGetLineHint(t *testing.T) {
|
|
58
|
+
content := "line1\nline2\nline3"
|
|
59
|
+
if h := GetLineHint(content, 0, 3); h != "L1" {
|
|
60
|
+
t.Errorf("same line: got %q, want L1", h)
|
|
61
|
+
}
|
|
62
|
+
if h := GetLineHint(content, 0, 12); h != "L1-L3" {
|
|
63
|
+
t.Errorf("multi line: got %q, want L1-L3", h)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func TestParseAndSerializeRoundTrip(t *testing.T) {
|
|
68
|
+
original := CommentFile{
|
|
69
|
+
Source: "/path/to/file.md",
|
|
70
|
+
Hash: "abcdef1234567890",
|
|
71
|
+
Version: 1,
|
|
72
|
+
Comments: []Comment{
|
|
73
|
+
{
|
|
74
|
+
ID: "abc12345",
|
|
75
|
+
SelectedText: "hello world",
|
|
76
|
+
Comment: "this is a comment",
|
|
77
|
+
CreatedAt: "2026-03-27T00:00:00Z",
|
|
78
|
+
LineHint: "L42",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
ID: "def67890",
|
|
82
|
+
SelectedText: "another selection",
|
|
83
|
+
Comment: "second comment",
|
|
84
|
+
CreatedAt: "2026-03-27T01:00:00Z",
|
|
85
|
+
LineHint: "L55-L60",
|
|
86
|
+
AnchorPrefix: "another",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
serialized := SerializeComments(original)
|
|
92
|
+
parsed, err := ParseCommentFile(serialized)
|
|
93
|
+
if err != nil {
|
|
94
|
+
t.Fatal(err)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if parsed.Source != original.Source {
|
|
98
|
+
t.Errorf("source: got %q, want %q", parsed.Source, original.Source)
|
|
99
|
+
}
|
|
100
|
+
if parsed.Hash != original.Hash {
|
|
101
|
+
t.Errorf("hash: got %q, want %q", parsed.Hash, original.Hash)
|
|
102
|
+
}
|
|
103
|
+
if len(parsed.Comments) != len(original.Comments) {
|
|
104
|
+
t.Fatalf("comments: got %d, want %d", len(parsed.Comments), len(original.Comments))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for i := range original.Comments {
|
|
108
|
+
if parsed.Comments[i].ID != original.Comments[i].ID {
|
|
109
|
+
t.Errorf("comment %d ID: got %q, want %q", i, parsed.Comments[i].ID, original.Comments[i].ID)
|
|
110
|
+
}
|
|
111
|
+
if parsed.Comments[i].SelectedText != original.Comments[i].SelectedText {
|
|
112
|
+
t.Errorf("comment %d selectedText: got %q, want %q", i, parsed.Comments[i].SelectedText, original.Comments[i].SelectedText)
|
|
113
|
+
}
|
|
114
|
+
if parsed.Comments[i].Comment != original.Comments[i].Comment {
|
|
115
|
+
t.Errorf("comment %d comment: got %q, want %q", i, parsed.Comments[i].Comment, original.Comments[i].Comment)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"html/template"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
type TemplateData struct {
|
|
10
|
+
Title string
|
|
11
|
+
CSSPath string
|
|
12
|
+
JSPath string
|
|
13
|
+
DocumentHTML template.HTML
|
|
14
|
+
InlineJSON template.JS
|
|
15
|
+
IsDev bool
|
|
16
|
+
FontFamily string
|
|
17
|
+
ProseClass string
|
|
18
|
+
ViteClient template.HTML
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pageTemplate = `<!DOCTYPE html>
|
|
22
|
+
<html lang="en">
|
|
23
|
+
<head>
|
|
24
|
+
<meta charset="UTF-8">
|
|
25
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
26
|
+
<title>readit — {{.Title}}</title>
|
|
27
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📖</text></svg>">
|
|
28
|
+
<script>
|
|
29
|
+
(() => {
|
|
30
|
+
var t = localStorage.getItem("readit:theme");
|
|
31
|
+
var d = t === "dark" || (t !== "light" && matchMedia("(prefers-color-scheme: dark)").matches);
|
|
32
|
+
if (d) document.documentElement.classList.add("dark");
|
|
33
|
+
})();
|
|
34
|
+
</script>
|
|
35
|
+
{{.ViteClient}}
|
|
36
|
+
{{if .CSSPath}}<link rel="stylesheet" href="{{.CSSPath}}">{{end}}
|
|
37
|
+
</head>
|
|
38
|
+
<body class="min-h-screen">
|
|
39
|
+
<article id="document-content" class="prose {{.ProseClass}}">{{.DocumentHTML}}</article>
|
|
40
|
+
<div id="app"></div>
|
|
41
|
+
<script type="application/json" id="__readit">{{.InlineJSON}}</script>
|
|
42
|
+
<script type="module" src="{{.JSPath}}"></script>
|
|
43
|
+
</body>
|
|
44
|
+
</html>`
|
|
45
|
+
|
|
46
|
+
func CompileTemplate() *template.Template {
|
|
47
|
+
return template.Must(template.New("page").Parse(pageTemplate))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func RenderPage(tmpl *template.Template, data TemplateData) (string, error) {
|
|
51
|
+
var b strings.Builder
|
|
52
|
+
if err := tmpl.Execute(&b, data); err != nil {
|
|
53
|
+
return "", err
|
|
54
|
+
}
|
|
55
|
+
return b.String(), nil
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// SafeJSONStringify serializes data for embedding in a <script> tag.
|
|
59
|
+
func SafeJSONStringify(data any) (template.JS, error) {
|
|
60
|
+
b, err := json.Marshal(data)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return "", err
|
|
63
|
+
}
|
|
64
|
+
s := strings.ReplaceAll(string(b), "<", `\u003c`)
|
|
65
|
+
return template.JS(s), nil
|
|
66
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
type Comment struct {
|
|
4
|
+
ID string `json:"id"`
|
|
5
|
+
SelectedText string `json:"selectedText"`
|
|
6
|
+
Comment string `json:"comment"`
|
|
7
|
+
CreatedAt string `json:"createdAt"`
|
|
8
|
+
StartOffset int `json:"startOffset"`
|
|
9
|
+
EndOffset int `json:"endOffset"`
|
|
10
|
+
LineHint string `json:"lineHint,omitempty"`
|
|
11
|
+
AnchorConfidence string `json:"anchorConfidence,omitempty"`
|
|
12
|
+
AnchorPrefix string `json:"anchorPrefix,omitempty"`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Heading struct {
|
|
16
|
+
ID string `json:"id"`
|
|
17
|
+
Text string `json:"text"`
|
|
18
|
+
Level int `json:"level"`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type FileState struct {
|
|
22
|
+
FilePath string
|
|
23
|
+
FileName string
|
|
24
|
+
Content []byte
|
|
25
|
+
RenderedHTML string
|
|
26
|
+
Headings []Heading
|
|
27
|
+
IsLoaded bool
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ShortcutBinding struct {
|
|
31
|
+
Key string `json:"key"`
|
|
32
|
+
Alt bool `json:"alt,omitempty"`
|
|
33
|
+
Ctrl bool `json:"ctrl,omitempty"`
|
|
34
|
+
Meta bool `json:"meta,omitempty"`
|
|
35
|
+
Shift bool `json:"shift,omitempty"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Keybinding struct {
|
|
39
|
+
ID string `json:"id"`
|
|
40
|
+
Binding *ShortcutBinding `json:"binding,omitempty"`
|
|
41
|
+
Enabled bool `json:"enabled"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Settings struct {
|
|
45
|
+
Version int `json:"version"`
|
|
46
|
+
FontFamily string `json:"fontFamily"`
|
|
47
|
+
Keybindings []Keybinding `json:"keybindings,omitempty"`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type CommentFile struct {
|
|
51
|
+
Source string
|
|
52
|
+
Hash string
|
|
53
|
+
Version int
|
|
54
|
+
Comments []Comment
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type InlineData struct {
|
|
58
|
+
Files []FileRef `json:"files"`
|
|
59
|
+
ActiveFile string `json:"activeFile"`
|
|
60
|
+
Clean bool `json:"clean"`
|
|
61
|
+
WorkingDir string `json:"workingDirectory"`
|
|
62
|
+
Documents map[string]InlineDocData `json:"documents"`
|
|
63
|
+
Settings Settings `json:"settings"`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type FileRef struct {
|
|
67
|
+
Path string `json:"path"`
|
|
68
|
+
FileName string `json:"fileName"`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type InlineDocData struct {
|
|
72
|
+
Headings []Heading `json:"headings"`
|
|
73
|
+
Comments []Comment `json:"comments"`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const (
|
|
77
|
+
AnchorExact = "exact"
|
|
78
|
+
AnchorNormalized = "normalized"
|
|
79
|
+
AnchorFuzzy = "fuzzy"
|
|
80
|
+
AnchorUnresolved = "unresolved"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const (
|
|
84
|
+
FontSerif = "serif"
|
|
85
|
+
FontSansSerif = "sans-serif"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const (
|
|
89
|
+
FormatVersion = 1
|
|
90
|
+
HashLength = 16
|
|
91
|
+
MaxSelectionLength = 1000
|
|
92
|
+
TruncationMarker = "\n...\n"
|
|
93
|
+
AnchorPrefixLength = 200
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const (
|
|
97
|
+
DefaultSearchWindow = 500
|
|
98
|
+
DefaultFuzzyThreshold = 5
|
|
99
|
+
MaxFuzzyTextLength = 200
|
|
100
|
+
FuzzySearchWindow = 2000
|
|
101
|
+
)
|