@peaske7/readit 0.2.0 → 0.3.0-rc.0

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 (179) 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 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +152 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +890 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +233 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/MermaidEnhancer.svelte +218 -0
  68. package/src/components/MermaidModal.svelte +67 -0
  69. package/src/components/RawModal.svelte +126 -0
  70. package/src/components/ReanchorConfirm.svelte +30 -0
  71. package/src/components/SettingsModal.svelte +220 -0
  72. package/src/components/ShortcutCapture.svelte +82 -0
  73. package/src/components/ShortcutList.svelte +145 -0
  74. package/src/components/TabBar.svelte +52 -0
  75. package/src/components/TableOfContents.svelte +125 -0
  76. package/src/components/ui/ActionLink.svelte +40 -0
  77. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  78. package/src/components/ui/Dialog.svelte +97 -0
  79. package/src/components/ui/DropdownMenu.svelte +85 -0
  80. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  81. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  82. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  83. package/src/env.d.ts +6 -0
  84. package/src/index.css +141 -166
  85. package/src/lib/__fixtures__/bench-data.ts +0 -13
  86. package/src/lib/anchor.bench.ts +1 -12
  87. package/src/lib/anchor.test.ts +0 -8
  88. package/src/lib/anchor.ts +0 -4
  89. package/src/lib/comment-storage.bench.ts +49 -0
  90. package/src/lib/comment-storage.test.ts +103 -33
  91. package/src/lib/comment-storage.ts +25 -18
  92. package/src/lib/export.bench.ts +21 -0
  93. package/src/lib/export.ts +0 -1
  94. package/src/lib/fetch-or-throw.test.ts +59 -0
  95. package/src/lib/fetch-or-throw.ts +12 -0
  96. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  97. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  98. package/src/lib/highlight/core.test.ts +0 -5
  99. package/src/lib/highlight/dom.ts +52 -216
  100. package/src/lib/highlight/highlight-registry.ts +221 -0
  101. package/src/lib/highlight/highlight.bench.ts +92 -0
  102. package/src/lib/highlight/highlighter.ts +112 -132
  103. package/src/lib/highlight/resolver.ts +5 -79
  104. package/src/lib/highlight/types.ts +0 -5
  105. package/src/lib/html-text.test.ts +162 -0
  106. package/src/lib/html-text.ts +161 -0
  107. package/src/lib/i18n/en.ts +34 -0
  108. package/src/lib/i18n/ja.ts +34 -0
  109. package/src/lib/i18n/types.ts +33 -0
  110. package/src/lib/key-lock.test.ts +104 -0
  111. package/src/lib/key-lock.ts +23 -0
  112. package/src/lib/margin-layout.bench.ts +61 -0
  113. package/src/lib/margin-layout.ts +0 -7
  114. package/src/lib/markdown-renderer.test.ts +154 -0
  115. package/src/lib/markdown-renderer.ts +178 -0
  116. package/src/lib/mermaid-config.ts +38 -0
  117. package/src/lib/mermaid-renderer.ts +162 -0
  118. package/src/lib/mermaid-worker.ts +60 -0
  119. package/src/lib/positions.ts +31 -24
  120. package/src/lib/shortcut-registry.ts +244 -0
  121. package/src/lib/utils.ts +0 -29
  122. package/src/main.ts +16 -0
  123. package/src/schema.ts +16 -5
  124. package/src/server.ts +355 -95
  125. package/src/stores/app.svelte.ts +231 -0
  126. package/src/stores/locale.svelte.ts +46 -0
  127. package/src/stores/settings.svelte.ts +90 -0
  128. package/src/stores/shortcuts.svelte.ts +104 -0
  129. package/src/stores/ui.svelte.ts +12 -0
  130. package/src/template.ts +104 -0
  131. package/src/test-setup.ts +47 -0
  132. package/svelte.config.js +5 -0
  133. package/tsconfig.json +2 -2
  134. package/vite.config.ts +23 -3
  135. package/vscode-readit/.mcp.json +7 -0
  136. package/vscode-readit/.vscodeignore +7 -0
  137. package/vscode-readit/bun.lock +78 -0
  138. package/vscode-readit/icon.svg +10 -0
  139. package/vscode-readit/package.json +110 -0
  140. package/vscode-readit/src/extension.ts +117 -0
  141. package/vscode-readit/src/server-manager.ts +272 -0
  142. package/vscode-readit/src/webview-provider.ts +204 -0
  143. package/vscode-readit/tsconfig.json +20 -0
  144. package/e2e/fixtures/sample.html +0 -13
  145. package/src/App.tsx +0 -368
  146. package/src/components/ActionsMenu.tsx +0 -91
  147. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  148. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  149. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  150. package/src/components/Header.tsx +0 -54
  151. package/src/components/InlineEditor.tsx +0 -74
  152. package/src/components/MarginNote.tsx +0 -185
  153. package/src/components/MarginNotes.tsx +0 -23
  154. package/src/components/RawModal.tsx +0 -144
  155. package/src/components/ReanchorConfirm.tsx +0 -36
  156. package/src/components/SettingsModal.tsx +0 -232
  157. package/src/components/TabBar.tsx +0 -60
  158. package/src/components/TableOfContents.tsx +0 -108
  159. package/src/components/comments/CommentBadge.tsx +0 -49
  160. package/src/components/comments/CommentInput.tsx +0 -86
  161. package/src/components/comments/CommentListItem.tsx +0 -90
  162. package/src/components/comments/CommentManager.tsx +0 -129
  163. package/src/components/comments/CommentNav.tsx +0 -109
  164. package/src/components/ui/ActionLink.tsx +0 -28
  165. package/src/components/ui/Dialog.tsx +0 -116
  166. package/src/components/ui/DropdownMenu.tsx +0 -158
  167. package/src/contexts/CommentContext.tsx +0 -198
  168. package/src/contexts/LocaleContext.tsx +0 -76
  169. package/src/contexts/PositionsContext.tsx +0 -16
  170. package/src/contexts/SettingsContext.tsx +0 -133
  171. package/src/hooks/useClickOutside.ts +0 -31
  172. package/src/hooks/useCommentNavigation.ts +0 -107
  173. package/src/hooks/useComments.ts +0 -311
  174. package/src/hooks/useDocument.ts +0 -157
  175. package/src/hooks/useScrollSpy.ts +0 -77
  176. package/src/hooks/useTextSelection.ts +0 -86
  177. package/src/lib/highlight/worker.ts +0 -45
  178. package/src/main.tsx +0 -13
  179. package/src/store.ts +0 -222
@@ -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, fmt.Sprintf("failed to resolve comment path: %v", err))
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, fmt.Sprintf("failed to resolve comment path: %v", err))
193
+ return
194
+ }
195
+ data, err := os.ReadFile(commentPath)
196
+ if err != nil {
197
+ writeError(w, http.StatusNotFound, fmt.Sprintf("comment file not found: %v", err))
198
+ return
199
+ }
200
+
201
+ cf, err := ParseCommentFile(data)
202
+ if err != nil {
203
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to parse comments: %v", err))
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, fmt.Sprintf("failed to save: %v", err))
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, fmt.Sprintf("failed to resolve comment path: %v", err))
235
+ return
236
+ }
237
+ data, err := os.ReadFile(commentPath)
238
+ if err != nil {
239
+ writeError(w, http.StatusNotFound, fmt.Sprintf("comment file not found: %v", err))
240
+ return
241
+ }
242
+
243
+ cf, err := ParseCommentFile(data)
244
+ if err != nil {
245
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to parse comments: %v", err))
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, fmt.Sprintf("failed to delete comment file: %v", err))
264
+ return
265
+ }
266
+ } else {
267
+ cf.Comments = filtered
268
+ if err := WriteCommentFile(commentPath, cf); err != nil {
269
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to save comments: %v", err))
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, fmt.Sprintf("failed to resolve comment path: %v", err))
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, fmt.Sprintf("failed to resolve comment path: %v", err))
325
+ return
326
+ }
327
+ data, err := os.ReadFile(commentPath)
328
+ if err != nil {
329
+ writeError(w, http.StatusNotFound, fmt.Sprintf("comment file not found: %v", err))
330
+ return
331
+ }
332
+
333
+ cf, err := ParseCommentFile(data)
334
+ if err != nil {
335
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to parse comments: %v", err))
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, fmt.Sprintf("failed to save: %v", err))
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, fmt.Sprintf("failed to resolve comment path: %v", err))
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
+ }