@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.
Files changed (221) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -5
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -710
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +130 -0
  12. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  13. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  14. package/e2e/comments.spec.ts +14 -58
  15. package/e2e/document-load.spec.ts +1 -23
  16. package/e2e/export.spec.ts +4 -4
  17. package/e2e/perf/add-comment.spec.ts +116 -0
  18. package/e2e/perf/fixtures/generate.ts +327 -0
  19. package/e2e/perf/initial-load.spec.ts +49 -0
  20. package/e2e/perf/perf.setup.ts +23 -0
  21. package/e2e/perf/perf.teardown.ts +9 -0
  22. package/e2e/perf/screenshot-final.png +0 -0
  23. package/e2e/perf/scroll.spec.ts +39 -0
  24. package/e2e/perf/tab-switch.spec.ts +69 -0
  25. package/e2e/perf/text-selection.spec.ts +119 -0
  26. package/e2e/perf/utils/metrics.ts +350 -0
  27. package/e2e/perf/utils/perf-cli.ts +86 -0
  28. package/e2e/persistence-file.spec.ts +41 -26
  29. package/e2e/utils/selection.ts +17 -73
  30. package/go/cmd/readit/main.go +416 -0
  31. package/go/go.mod +20 -0
  32. package/go/go.sum +41 -0
  33. package/go/internal/server/anchor.go +302 -0
  34. package/go/internal/server/anchor_test.go +111 -0
  35. package/go/internal/server/comments.go +390 -0
  36. package/go/internal/server/documents.go +113 -0
  37. package/go/internal/server/embed.go +17 -0
  38. package/go/internal/server/headings.go +33 -0
  39. package/go/internal/server/headings_test.go +75 -0
  40. package/go/internal/server/htmltext.go +123 -0
  41. package/go/internal/server/markdown.go +157 -0
  42. package/go/internal/server/markdown_bench_test.go +42 -0
  43. package/go/internal/server/markdown_test.go +79 -0
  44. package/go/internal/server/server.go +453 -0
  45. package/go/internal/server/server_bench_test.go +122 -0
  46. package/go/internal/server/settings.go +110 -0
  47. package/go/internal/server/sse.go +140 -0
  48. package/go/internal/server/storage.go +275 -0
  49. package/go/internal/server/storage_test.go +118 -0
  50. package/go/internal/server/template.go +66 -0
  51. package/go/internal/server/types.go +101 -0
  52. package/go/internal/server/watcher.go +74 -0
  53. package/index.html +4 -14
  54. package/nvim-readit/lua/readit/health.lua +64 -0
  55. package/nvim-readit/lua/readit/init.lua +463 -0
  56. package/nvim-readit/plugin/readit.lua +19 -0
  57. package/package.json +24 -41
  58. package/playwright.config.ts +12 -0
  59. package/shell/_readit +158 -0
  60. package/shell/readit.zsh +87 -0
  61. package/src/App.svelte +881 -0
  62. package/src/{cli/index.ts → cli.ts} +216 -70
  63. package/src/components/ActionsMenu.svelte +95 -0
  64. package/src/components/CommentBadge.svelte +67 -0
  65. package/src/components/CommentErrorBanner.svelte +33 -0
  66. package/src/components/CommentInput.svelte +75 -0
  67. package/src/components/CommentListItem.svelte +95 -0
  68. package/src/components/CommentManager.svelte +129 -0
  69. package/src/components/CommentNav.svelte +109 -0
  70. package/src/components/DocumentViewer.svelte +218 -0
  71. package/src/components/FloatingComment.svelte +107 -0
  72. package/src/components/Header.svelte +76 -0
  73. package/src/components/InlineEditor.svelte +72 -0
  74. package/src/components/MarginNote.svelte +167 -0
  75. package/src/components/MarginNotesContainer.svelte +33 -0
  76. package/src/components/RawModal.svelte +126 -0
  77. package/src/components/ReanchorConfirm.svelte +30 -0
  78. package/src/components/SettingsModal.svelte +220 -0
  79. package/src/components/ShortcutCapture.svelte +82 -0
  80. package/src/components/ShortcutList.svelte +145 -0
  81. package/src/components/TabBar.svelte +52 -0
  82. package/src/components/TableOfContents.svelte +125 -0
  83. package/src/components/ui/ActionLink.svelte +40 -0
  84. package/src/components/ui/Button.svelte +53 -0
  85. package/src/components/ui/Dialog.svelte +97 -0
  86. package/src/components/ui/DropdownMenu.svelte +85 -0
  87. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  88. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  89. package/src/components/ui/Text.svelte +42 -0
  90. package/src/env.d.ts +6 -0
  91. package/src/index.css +36 -166
  92. package/src/lib/__fixtures__/bench-data.ts +1 -54
  93. package/src/lib/anchor.bench.ts +47 -68
  94. package/src/lib/anchor.test.ts +5 -9
  95. package/src/lib/anchor.ts +9 -93
  96. package/src/lib/comment-storage.bench.ts +6 -20
  97. package/src/lib/comment-storage.test.ts +45 -37
  98. package/src/lib/comment-storage.ts +23 -64
  99. package/src/lib/export.bench.ts +9 -23
  100. package/src/lib/export.ts +7 -14
  101. package/src/lib/headings.test.ts +103 -0
  102. package/src/lib/headings.ts +44 -0
  103. package/src/lib/highlight/core.test.ts +1 -6
  104. package/src/lib/highlight/dom.ts +53 -280
  105. package/src/lib/highlight/highlight-registry.ts +221 -0
  106. package/src/lib/highlight/highlight.bench.ts +92 -0
  107. package/src/lib/highlight/highlighter.ts +122 -302
  108. package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
  109. package/src/lib/highlight/types.ts +0 -40
  110. package/src/lib/html-text.test.ts +162 -0
  111. package/src/lib/html-text.ts +161 -0
  112. package/src/lib/i18n/en.ts +13 -36
  113. package/src/lib/i18n/ja.ts +14 -37
  114. package/src/lib/i18n/types.ts +13 -36
  115. package/src/lib/margin-layout.bench.ts +48 -15
  116. package/src/lib/margin-layout.ts +2 -31
  117. package/src/lib/markdown-renderer.test.ts +154 -0
  118. package/src/lib/markdown-renderer.ts +177 -0
  119. package/src/lib/mermaid-config.ts +38 -0
  120. package/src/lib/mermaid-renderer.ts +162 -0
  121. package/src/lib/mermaid-worker.ts +60 -0
  122. package/src/lib/positions.ts +157 -0
  123. package/src/lib/shortcut-registry.ts +138 -103
  124. package/src/lib/utils.ts +2 -48
  125. package/src/main.ts +16 -0
  126. package/src/schema.ts +92 -0
  127. package/src/{server/index.ts → server.ts} +427 -163
  128. package/src/stores/app.svelte.ts +231 -0
  129. package/src/stores/locale.svelte.ts +46 -0
  130. package/src/stores/settings.svelte.ts +90 -0
  131. package/src/stores/shortcuts.svelte.ts +104 -0
  132. package/src/stores/ui.svelte.ts +12 -0
  133. package/src/template.ts +104 -0
  134. package/src/test-setup.ts +47 -0
  135. package/svelte.config.js +5 -0
  136. package/tsconfig.json +2 -2
  137. package/vite.config.ts +31 -3
  138. package/vscode-readit/.mcp.json +7 -0
  139. package/vscode-readit/.vscodeignore +7 -0
  140. package/vscode-readit/bun.lock +78 -0
  141. package/vscode-readit/icon.svg +10 -0
  142. package/vscode-readit/package.json +110 -0
  143. package/vscode-readit/src/extension.ts +117 -0
  144. package/vscode-readit/src/server-manager.ts +272 -0
  145. package/vscode-readit/src/webview-provider.ts +204 -0
  146. package/vscode-readit/tsconfig.json +20 -0
  147. package/e2e/fixtures/sample.html +0 -13
  148. package/src/App.tsx +0 -416
  149. package/src/components/ActionsMenu.tsx +0 -112
  150. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  151. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
  152. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  153. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  154. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
  155. package/src/components/DocumentViewer/index.ts +0 -1
  156. package/src/components/FloatingTOC.tsx +0 -61
  157. package/src/components/Header.tsx +0 -65
  158. package/src/components/InlineEditor.tsx +0 -74
  159. package/src/components/MarginNote.tsx +0 -207
  160. package/src/components/MarginNotes.tsx +0 -50
  161. package/src/components/RawModal.tsx +0 -143
  162. package/src/components/ReanchorConfirm.tsx +0 -36
  163. package/src/components/SettingsModal.tsx +0 -310
  164. package/src/components/ShortcutCapture.tsx +0 -48
  165. package/src/components/ShortcutList.tsx +0 -198
  166. package/src/components/TabBar.tsx +0 -60
  167. package/src/components/TableOfContents.tsx +0 -108
  168. package/src/components/comments/CommentBadge.tsx +0 -49
  169. package/src/components/comments/CommentInput.tsx +0 -114
  170. package/src/components/comments/CommentListItem.tsx +0 -92
  171. package/src/components/comments/CommentManager.tsx +0 -113
  172. package/src/components/comments/CommentMinimap.tsx +0 -62
  173. package/src/components/comments/CommentNav.tsx +0 -109
  174. package/src/components/ui/ActionBar.tsx +0 -16
  175. package/src/components/ui/ActionLink.tsx +0 -32
  176. package/src/components/ui/Button.tsx +0 -55
  177. package/src/components/ui/Dialog.tsx +0 -156
  178. package/src/components/ui/DropdownMenu.tsx +0 -114
  179. package/src/components/ui/SeparatorDot.tsx +0 -9
  180. package/src/components/ui/Text.tsx +0 -54
  181. package/src/contexts/CommentContext.tsx +0 -229
  182. package/src/contexts/LayoutContext.tsx +0 -88
  183. package/src/contexts/LocaleContext.tsx +0 -35
  184. package/src/hooks/useClickOutside.ts +0 -35
  185. package/src/hooks/useClipboard.ts +0 -82
  186. package/src/hooks/useCommentNavigation.ts +0 -130
  187. package/src/hooks/useComments.ts +0 -323
  188. package/src/hooks/useDocument.ts +0 -156
  189. package/src/hooks/useEditorScheme.ts +0 -51
  190. package/src/hooks/useFontPreference.ts +0 -59
  191. package/src/hooks/useHeadings.test.ts +0 -159
  192. package/src/hooks/useHeadings.ts +0 -129
  193. package/src/hooks/useKeybindings.ts +0 -108
  194. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  195. package/src/hooks/useLayoutMode.ts +0 -44
  196. package/src/hooks/useLocalePreference.ts +0 -42
  197. package/src/hooks/useReanchorMode.ts +0 -33
  198. package/src/hooks/useScrollMetrics.ts +0 -56
  199. package/src/hooks/useScrollSpy.ts +0 -81
  200. package/src/hooks/useTextSelection.ts +0 -123
  201. package/src/hooks/useThemePreference.ts +0 -66
  202. package/src/lib/context.bench.ts +0 -41
  203. package/src/lib/context.test.ts +0 -224
  204. package/src/lib/context.ts +0 -193
  205. package/src/lib/editor-links.ts +0 -59
  206. package/src/lib/highlight/colors.ts +0 -37
  207. package/src/lib/highlight/index.ts +0 -23
  208. package/src/lib/highlight/script-builder.ts +0 -485
  209. package/src/lib/html-processor.test.tsx +0 -170
  210. package/src/lib/html-processor.tsx +0 -95
  211. package/src/lib/i18n/completeness.test.ts +0 -51
  212. package/src/lib/i18n/translations.test.ts +0 -39
  213. package/src/lib/layout-constants.ts +0 -12
  214. package/src/lib/scroll.test.ts +0 -118
  215. package/src/lib/scroll.ts +0 -47
  216. package/src/lib/shortcut-registry.test.ts +0 -173
  217. package/src/lib/utils.test.ts +0 -110
  218. package/src/main.tsx +0 -13
  219. package/src/store/index.test.ts +0 -242
  220. package/src/store/index.ts +0 -254
  221. package/src/types/index.ts +0 -127
@@ -0,0 +1,390 @@
1
+ package server
2
+
3
+ import (
4
+ "fmt"
5
+ "log"
6
+ "net/http"
7
+ "os"
8
+ "strings"
9
+ "sync"
10
+ )
11
+
12
+ func (s *Server) commentLock(path string) *sync.Mutex {
13
+ s.commentFileMu.Lock()
14
+ defer s.commentFileMu.Unlock()
15
+ mu, ok := s.commentFileLocks[path]
16
+ if !ok {
17
+ mu = &sync.Mutex{}
18
+ s.commentFileLocks[path] = mu
19
+ }
20
+ return mu
21
+ }
22
+
23
+ func (s *Server) resolveCommentsFor(path string, state *FileState) []Comment {
24
+ commentPath, err := CommentPath(path)
25
+ if err != nil {
26
+ log.Printf("Warning: failed to resolve comment path for %s: %v", path, err)
27
+ return []Comment{}
28
+ }
29
+
30
+ s.commentCacheMu.RLock()
31
+ cached := s.commentCache[path]
32
+ s.commentCacheMu.RUnlock()
33
+
34
+ sourceHash := ComputeHash(state.Content)
35
+
36
+ info, err := os.Stat(commentPath)
37
+ if err != nil {
38
+ if os.IsNotExist(err) {
39
+ return []Comment{}
40
+ }
41
+ log.Printf("Warning: unexpected error checking comment file %s: %v", commentPath, err)
42
+ return []Comment{}
43
+ }
44
+
45
+ mtimeMs := info.ModTime().UnixMilli()
46
+ if cached != nil && cached.sourceHash == sourceHash && cached.commentMtimeMs == mtimeMs {
47
+ return cached.comments
48
+ }
49
+
50
+ data, err := os.ReadFile(commentPath)
51
+ if err != nil {
52
+ return []Comment{}
53
+ }
54
+
55
+ cf, err := ParseCommentFile(data)
56
+ if err != nil {
57
+ return []Comment{}
58
+ }
59
+
60
+ sourceContent := string(state.Content)
61
+ domText := ExtractTextFromHTML(state.RenderedHTML)
62
+
63
+ resolved := make([]Comment, 0, len(cf.Comments))
64
+ for _, c := range cf.Comments {
65
+ searchText := c.SelectedText
66
+ if c.AnchorPrefix != "" {
67
+ searchText = c.AnchorPrefix
68
+ }
69
+
70
+ result := FindAnchorWithFallback(sourceContent, searchText, c.LineHint)
71
+ if result != nil {
72
+ c.StartOffset = result.StartOffset
73
+ c.EndOffset = result.EndOffset
74
+ c.AnchorConfidence = result.Confidence
75
+
76
+ if start, end, ok := FindTextPosition(domText, searchText, result.StartOffset); ok {
77
+ c.StartOffset = start
78
+ c.EndOffset = end
79
+ }
80
+ } else {
81
+ c.AnchorConfidence = AnchorUnresolved
82
+ }
83
+
84
+ resolved = append(resolved, c)
85
+ }
86
+
87
+ s.commentCacheMu.Lock()
88
+ s.commentCache[path] = &resolvedCacheEntry{
89
+ commentMtimeMs: mtimeMs,
90
+ sourceHash: sourceHash,
91
+ comments: resolved,
92
+ }
93
+ s.commentCacheMu.Unlock()
94
+
95
+ return resolved
96
+ }
97
+
98
+ func (s *Server) listComments(w http.ResponseWriter, r *http.Request) {
99
+ path := s.resolveFilePath(r)
100
+ state := s.getFileState(path)
101
+ if state == nil {
102
+ writeError(w, http.StatusNotFound, "document not found")
103
+ return
104
+ }
105
+
106
+ comments := s.resolveCommentsFor(path, state)
107
+ writeJSON(w, http.StatusOK, map[string]any{"comments": comments})
108
+ }
109
+
110
+ func (s *Server) createComment(w http.ResponseWriter, r *http.Request) {
111
+ path := s.resolveFilePath(r)
112
+ state := s.getFileState(path)
113
+ if state == nil {
114
+ writeError(w, http.StatusNotFound, "document not found")
115
+ return
116
+ }
117
+
118
+ var body struct {
119
+ SelectedText string `json:"selectedText"`
120
+ Comment string `json:"comment"`
121
+ StartOffset int `json:"startOffset"`
122
+ EndOffset int `json:"endOffset"`
123
+ }
124
+ if err := readJSON(r, &body); err != nil {
125
+ writeError(w, http.StatusBadRequest, "invalid request body")
126
+ return
127
+ }
128
+
129
+ if body.SelectedText == "" || body.Comment == "" {
130
+ writeError(w, http.StatusBadRequest, "selectedText and comment are required")
131
+ return
132
+ }
133
+
134
+ c := CreateComment(body.SelectedText, body.Comment, body.StartOffset, body.EndOffset, string(state.Content))
135
+
136
+ mu := s.commentLock(path)
137
+ mu.Lock()
138
+ defer mu.Unlock()
139
+
140
+ commentPath, err := CommentPath(path)
141
+ if err != nil {
142
+ writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
143
+ return
144
+ }
145
+ cf := CommentFile{
146
+ Source: path,
147
+ Hash: ComputeHash(state.Content),
148
+ Version: FormatVersion,
149
+ }
150
+
151
+ if data, err := os.ReadFile(commentPath); err == nil {
152
+ parsed, parseErr := ParseCommentFile(data)
153
+ if parseErr != nil {
154
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to parse existing comment file: %v", parseErr))
155
+ return
156
+ }
157
+ cf = parsed
158
+ } else if !os.IsNotExist(err) {
159
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read comment file: %v", err))
160
+ return
161
+ }
162
+ cf.Hash = ComputeHash(state.Content)
163
+ cf.Comments = append(cf.Comments, c)
164
+
165
+ if err := WriteCommentFile(commentPath, cf); err != nil {
166
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to save comment: %v", err))
167
+ return
168
+ }
169
+ s.invalidateCommentCache(path)
170
+
171
+ writeJSON(w, http.StatusCreated, map[string]any{"comment": c})
172
+ }
173
+
174
+ func (s *Server) updateComment(w http.ResponseWriter, r *http.Request) {
175
+ path := s.resolveFilePath(r)
176
+ commentID := r.PathValue("id")
177
+
178
+ var body struct {
179
+ Comment string `json:"comment"`
180
+ }
181
+ if err := readJSON(r, &body); err != nil {
182
+ writeError(w, http.StatusBadRequest, "invalid request body")
183
+ return
184
+ }
185
+
186
+ mu := s.commentLock(path)
187
+ mu.Lock()
188
+ defer mu.Unlock()
189
+
190
+ commentPath, err := CommentPath(path)
191
+ if err != nil {
192
+ writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
193
+ return
194
+ }
195
+ data, err := os.ReadFile(commentPath)
196
+ if err != nil {
197
+ writeError(w, http.StatusNotFound, "comment file not found")
198
+ return
199
+ }
200
+
201
+ cf, err := ParseCommentFile(data)
202
+ if err != nil {
203
+ writeError(w, http.StatusInternalServerError, "failed to parse comments")
204
+ return
205
+ }
206
+
207
+ for i := range cf.Comments {
208
+ if cf.Comments[i].ID == commentID {
209
+ cf.Comments[i].Comment = strings.TrimSpace(body.Comment)
210
+
211
+ if err := WriteCommentFile(commentPath, cf); err != nil {
212
+ writeError(w, http.StatusInternalServerError, "failed to save")
213
+ return
214
+ }
215
+ s.invalidateCommentCache(path)
216
+ writeJSON(w, http.StatusOK, map[string]any{"comment": cf.Comments[i]})
217
+ return
218
+ }
219
+ }
220
+
221
+ writeError(w, http.StatusNotFound, "comment not found")
222
+ }
223
+
224
+ func (s *Server) deleteComment(w http.ResponseWriter, r *http.Request) {
225
+ path := s.resolveFilePath(r)
226
+ commentID := r.PathValue("id")
227
+
228
+ mu := s.commentLock(path)
229
+ mu.Lock()
230
+ defer mu.Unlock()
231
+
232
+ commentPath, err := CommentPath(path)
233
+ if err != nil {
234
+ writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
235
+ return
236
+ }
237
+ data, err := os.ReadFile(commentPath)
238
+ if err != nil {
239
+ writeError(w, http.StatusNotFound, "comment file not found")
240
+ return
241
+ }
242
+
243
+ cf, err := ParseCommentFile(data)
244
+ if err != nil {
245
+ writeError(w, http.StatusInternalServerError, "failed to parse comments")
246
+ return
247
+ }
248
+
249
+ filtered := make([]Comment, 0, len(cf.Comments))
250
+ for _, c := range cf.Comments {
251
+ if c.ID != commentID {
252
+ filtered = append(filtered, c)
253
+ }
254
+ }
255
+
256
+ if len(filtered) == len(cf.Comments) {
257
+ writeError(w, http.StatusNotFound, "comment not found")
258
+ return
259
+ }
260
+
261
+ if len(filtered) == 0 {
262
+ if err := os.Remove(commentPath); err != nil && !os.IsNotExist(err) {
263
+ writeError(w, http.StatusInternalServerError, "failed to delete comment file")
264
+ return
265
+ }
266
+ } else {
267
+ cf.Comments = filtered
268
+ if err := WriteCommentFile(commentPath, cf); err != nil {
269
+ writeError(w, http.StatusInternalServerError, "failed to save comments")
270
+ return
271
+ }
272
+ }
273
+ s.invalidateCommentCache(path)
274
+
275
+ writeJSON(w, http.StatusOK, map[string]bool{"success": true})
276
+ }
277
+
278
+ func (s *Server) deleteAllComments(w http.ResponseWriter, r *http.Request) {
279
+ path := s.resolveFilePath(r)
280
+
281
+ mu := s.commentLock(path)
282
+ mu.Lock()
283
+ defer mu.Unlock()
284
+
285
+ commentPath, err := CommentPath(path)
286
+ if err != nil {
287
+ writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
288
+ return
289
+ }
290
+ if err := os.Remove(commentPath); err != nil && !os.IsNotExist(err) {
291
+ writeError(w, http.StatusInternalServerError, "failed to delete comment file")
292
+ return
293
+ }
294
+ s.invalidateCommentCache(path)
295
+ writeJSON(w, http.StatusOK, map[string]bool{"success": true})
296
+ }
297
+
298
+ func (s *Server) reanchorComment(w http.ResponseWriter, r *http.Request) {
299
+ path := s.resolveFilePath(r)
300
+ state := s.getFileState(path)
301
+ if state == nil {
302
+ writeError(w, http.StatusNotFound, "document not found")
303
+ return
304
+ }
305
+
306
+ commentID := r.PathValue("id")
307
+
308
+ var body struct {
309
+ SelectedText string `json:"selectedText"`
310
+ StartOffset int `json:"startOffset"`
311
+ EndOffset int `json:"endOffset"`
312
+ }
313
+ if err := readJSON(r, &body); err != nil {
314
+ writeError(w, http.StatusBadRequest, "invalid request body")
315
+ return
316
+ }
317
+
318
+ mu := s.commentLock(path)
319
+ mu.Lock()
320
+ defer mu.Unlock()
321
+
322
+ commentPath, err := CommentPath(path)
323
+ if err != nil {
324
+ writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
325
+ return
326
+ }
327
+ data, err := os.ReadFile(commentPath)
328
+ if err != nil {
329
+ writeError(w, http.StatusNotFound, "comment file not found")
330
+ return
331
+ }
332
+
333
+ cf, err := ParseCommentFile(data)
334
+ if err != nil {
335
+ writeError(w, http.StatusInternalServerError, "failed to parse comments")
336
+ return
337
+ }
338
+
339
+ sourceContent := string(state.Content)
340
+ for i := range cf.Comments {
341
+ if cf.Comments[i].ID == commentID {
342
+ truncated := TruncateSelection(body.SelectedText)
343
+ cf.Comments[i].SelectedText = truncated
344
+ cf.Comments[i].StartOffset = body.StartOffset
345
+ cf.Comments[i].EndOffset = body.EndOffset
346
+ cf.Comments[i].LineHint = GetLineHint(sourceContent, body.StartOffset, body.EndOffset)
347
+ cf.Comments[i].AnchorConfidence = AnchorExact
348
+
349
+ if len(body.SelectedText) > MaxSelectionLength {
350
+ cf.Comments[i].AnchorPrefix = body.SelectedText[:min(AnchorPrefixLength, len(body.SelectedText))]
351
+ } else {
352
+ cf.Comments[i].AnchorPrefix = ""
353
+ }
354
+
355
+ cf.Hash = ComputeHash(state.Content)
356
+ if err := WriteCommentFile(commentPath, cf); err != nil {
357
+ writeError(w, http.StatusInternalServerError, "failed to save")
358
+ return
359
+ }
360
+ s.invalidateCommentCache(path)
361
+ writeJSON(w, http.StatusOK, map[string]any{"comment": cf.Comments[i]})
362
+ return
363
+ }
364
+ }
365
+
366
+ writeError(w, http.StatusNotFound, "comment not found")
367
+ }
368
+
369
+ func (s *Server) rawComments(w http.ResponseWriter, r *http.Request) {
370
+ path := s.resolveFilePath(r)
371
+ commentPath, err := CommentPath(path)
372
+ if err != nil {
373
+ writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
374
+ return
375
+ }
376
+
377
+ data, err := os.ReadFile(commentPath)
378
+ if err != nil {
379
+ writeJSON(w, http.StatusOK, map[string]any{
380
+ "content": nil,
381
+ "path": commentPath,
382
+ })
383
+ return
384
+ }
385
+
386
+ writeJSON(w, http.StatusOK, map[string]any{
387
+ "content": string(data),
388
+ "path": commentPath,
389
+ })
390
+ }
@@ -0,0 +1,113 @@
1
+ package server
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "path/filepath"
7
+ "strings"
8
+ )
9
+
10
+ func (s *Server) listDocuments(w http.ResponseWriter, r *http.Request) {
11
+ s.mu.RLock()
12
+ files := make([]FileRef, 0, len(s.fileOrder))
13
+ for _, p := range s.fileOrder {
14
+ if f, ok := s.files[p]; ok {
15
+ files = append(files, FileRef{Path: p, FileName: f.FileName})
16
+ }
17
+ }
18
+ clean := s.clean
19
+ workingDir := s.workingDir
20
+ s.mu.RUnlock()
21
+
22
+ writeJSON(w, http.StatusOK, map[string]any{
23
+ "files": files,
24
+ "clean": clean,
25
+ "workingDirectory": workingDir,
26
+ })
27
+ }
28
+
29
+ func (s *Server) addDocument(w http.ResponseWriter, r *http.Request) {
30
+ var body struct {
31
+ Path string `json:"path"`
32
+ }
33
+ if err := readJSON(r, &body); err != nil || body.Path == "" {
34
+ writeError(w, http.StatusBadRequest, "path is required")
35
+ return
36
+ }
37
+
38
+ absPath, err := filepath.Abs(body.Path)
39
+ if err != nil {
40
+ writeError(w, http.StatusBadRequest, "invalid path")
41
+ return
42
+ }
43
+ absPath, err = filepath.EvalSymlinks(absPath)
44
+ if err != nil {
45
+ writeError(w, http.StatusNotFound, "file not found")
46
+ return
47
+ }
48
+
49
+ if !isMarkdownFile(absPath) {
50
+ writeError(w, http.StatusBadRequest, "only markdown files are supported")
51
+ return
52
+ }
53
+
54
+ s.mu.RLock()
55
+ _, exists := s.files[absPath]
56
+ s.mu.RUnlock()
57
+
58
+ if exists {
59
+ writeJSON(w, http.StatusOK, map[string]string{
60
+ "path": absPath,
61
+ "fileName": filepath.Base(absPath),
62
+ "status": "present",
63
+ })
64
+ return
65
+ }
66
+
67
+ if err := s.loadFile(FileEntry{FilePath: absPath}); err != nil {
68
+ writeError(w, http.StatusInternalServerError, "failed to load file")
69
+ return
70
+ }
71
+
72
+ fileName := filepath.Base(absPath)
73
+
74
+ event, _ := json.Marshal(map[string]string{
75
+ "type": "document-added",
76
+ "path": absPath,
77
+ "fileName": fileName,
78
+ })
79
+ s.sse.Broadcast(string(event))
80
+
81
+ writeJSON(w, http.StatusOK, map[string]string{
82
+ "path": absPath,
83
+ "fileName": fileName,
84
+ "status": "added",
85
+ })
86
+ }
87
+
88
+ func (s *Server) getDocument(w http.ResponseWriter, r *http.Request) {
89
+ path := s.resolveFilePath(r)
90
+
91
+ s.mu.RLock()
92
+ state := s.files[path]
93
+ clean := s.clean
94
+ s.mu.RUnlock()
95
+
96
+ if state == nil {
97
+ writeError(w, http.StatusNotFound, "document not found")
98
+ return
99
+ }
100
+
101
+ writeJSON(w, http.StatusOK, map[string]any{
102
+ "html": state.RenderedHTML,
103
+ "headings": state.Headings,
104
+ "filePath": state.FilePath,
105
+ "fileName": state.FileName,
106
+ "clean": clean,
107
+ })
108
+ }
109
+
110
+ func isMarkdownFile(path string) bool {
111
+ ext := strings.ToLower(filepath.Ext(path))
112
+ return ext == ".md" || ext == ".markdown"
113
+ }
@@ -0,0 +1,17 @@
1
+ package server
2
+
3
+ import (
4
+ "embed"
5
+ "io/fs"
6
+ )
7
+
8
+ //go:embed all:dist
9
+ var embeddedAssets embed.FS
10
+
11
+ func EmbeddedAssetsFS() fs.FS {
12
+ sub, err := fs.Sub(embeddedAssets, "dist")
13
+ if err != nil {
14
+ return nil
15
+ }
16
+ return sub
17
+ }
@@ -0,0 +1,33 @@
1
+ package server
2
+
3
+ import (
4
+ "bytes"
5
+ "strings"
6
+
7
+ "github.com/yuin/goldmark"
8
+ "github.com/yuin/goldmark/ast"
9
+ "github.com/yuin/goldmark/parser"
10
+ "github.com/yuin/goldmark/text"
11
+ )
12
+
13
+ // ExtractHeadings parses markdown source and returns headings using goldmark's AST.
14
+ // This ensures heading IDs match exactly what goldmark renders in HTML.
15
+ func ExtractHeadings(source []byte) []Heading {
16
+ md := goldmark.New(
17
+ goldmark.WithParserOptions(
18
+ parser.WithAutoHeadingID(),
19
+ ),
20
+ )
21
+
22
+ reader := text.NewReader(source)
23
+ doc := md.Parser().Parse(reader)
24
+
25
+ return extractHeadingsFromAST(doc, source)
26
+ }
27
+
28
+ // collectHeadingText recursively collects plain text from heading AST nodes.
29
+ func collectHeadingText(n ast.Node, source []byte) string {
30
+ var buf bytes.Buffer
31
+ collectText(n, source, &buf)
32
+ return strings.TrimSpace(buf.String())
33
+ }
@@ -0,0 +1,75 @@
1
+ package server
2
+
3
+ import "testing"
4
+
5
+ func TestExtractHeadingsDeduplicate(t *testing.T) {
6
+ src := []byte("# Title\n\n## Title\n\n### Title\n")
7
+ headings := ExtractHeadings(src)
8
+
9
+ if len(headings) != 3 {
10
+ t.Fatalf("expected 3 headings, got %d", len(headings))
11
+ }
12
+
13
+ if headings[0].ID != "title" {
14
+ t.Errorf("heading 0 ID = %q, want %q", headings[0].ID, "title")
15
+ }
16
+ if headings[1].ID != "title-1" {
17
+ t.Errorf("heading 1 ID = %q, want %q", headings[1].ID, "title-1")
18
+ }
19
+ if headings[2].ID != "title-2" {
20
+ t.Errorf("heading 2 ID = %q, want %q", headings[2].ID, "title-2")
21
+ }
22
+ }
23
+
24
+ func TestExtractHeadingsSkipsCodeBlocks(t *testing.T) {
25
+ src := []byte("# Real Heading\n\n```\n# Not a heading\n```\n\n## Another Real\n")
26
+ headings := ExtractHeadings(src)
27
+
28
+ if len(headings) != 2 {
29
+ t.Fatalf("expected 2 headings, got %d", len(headings))
30
+ }
31
+
32
+ if headings[0].Text != "Real Heading" {
33
+ t.Errorf("heading 0 text = %q", headings[0].Text)
34
+ }
35
+ if headings[1].Text != "Another Real" {
36
+ t.Errorf("heading 1 text = %q", headings[1].Text)
37
+ }
38
+ }
39
+
40
+ func TestExtractHeadingsLevels(t *testing.T) {
41
+ src := []byte("# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6\n")
42
+ headings := ExtractHeadings(src)
43
+
44
+ if len(headings) != 6 {
45
+ t.Fatalf("expected 6 headings, got %d", len(headings))
46
+ }
47
+
48
+ for i, h := range headings {
49
+ if h.Level != i+1 {
50
+ t.Errorf("heading %d level = %d, want %d", i, h.Level, i+1)
51
+ }
52
+ }
53
+ }
54
+
55
+ func TestExtractHeadingsSetextStyle(t *testing.T) {
56
+ src := []byte("Setext H1\n=========\n\nSetext H2\n---------\n")
57
+ headings := ExtractHeadings(src)
58
+
59
+ if len(headings) != 2 {
60
+ t.Fatalf("expected 2 headings, got %d", len(headings))
61
+ }
62
+
63
+ if headings[0].Text != "Setext H1" {
64
+ t.Errorf("heading 0 text = %q, want %q", headings[0].Text, "Setext H1")
65
+ }
66
+ if headings[0].Level != 1 {
67
+ t.Errorf("heading 0 level = %d, want 1", headings[0].Level)
68
+ }
69
+ if headings[1].Text != "Setext H2" {
70
+ t.Errorf("heading 1 text = %q, want %q", headings[1].Text, "Setext H2")
71
+ }
72
+ if headings[1].Level != 2 {
73
+ t.Errorf("heading 1 level = %d, want 2", headings[1].Level)
74
+ }
75
+ }