@peaske7/readit 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) 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 +118 -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 +881 -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 +218 -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/RawModal.svelte +126 -0
  68. package/src/components/ReanchorConfirm.svelte +30 -0
  69. package/src/components/SettingsModal.svelte +220 -0
  70. package/src/components/ShortcutCapture.svelte +82 -0
  71. package/src/components/ShortcutList.svelte +145 -0
  72. package/src/components/TabBar.svelte +52 -0
  73. package/src/components/TableOfContents.svelte +125 -0
  74. package/src/components/ui/ActionLink.svelte +40 -0
  75. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  76. package/src/components/ui/Dialog.svelte +97 -0
  77. package/src/components/ui/DropdownMenu.svelte +85 -0
  78. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  79. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  80. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  81. package/src/env.d.ts +6 -0
  82. package/src/index.css +36 -166
  83. package/src/lib/__fixtures__/bench-data.ts +0 -13
  84. package/src/lib/anchor.bench.ts +1 -12
  85. package/src/lib/anchor.test.ts +0 -8
  86. package/src/lib/anchor.ts +0 -4
  87. package/src/lib/comment-storage.bench.ts +49 -0
  88. package/src/lib/comment-storage.test.ts +41 -33
  89. package/src/lib/comment-storage.ts +21 -18
  90. package/src/lib/export.bench.ts +21 -0
  91. package/src/lib/export.ts +0 -1
  92. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  93. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  94. package/src/lib/highlight/core.test.ts +0 -5
  95. package/src/lib/highlight/dom.ts +52 -216
  96. package/src/lib/highlight/highlight-registry.ts +221 -0
  97. package/src/lib/highlight/highlight.bench.ts +92 -0
  98. package/src/lib/highlight/highlighter.ts +112 -132
  99. package/src/lib/highlight/resolver.ts +5 -79
  100. package/src/lib/highlight/types.ts +0 -5
  101. package/src/lib/html-text.test.ts +162 -0
  102. package/src/lib/html-text.ts +161 -0
  103. package/src/lib/i18n/en.ts +26 -0
  104. package/src/lib/i18n/ja.ts +26 -0
  105. package/src/lib/i18n/types.ts +25 -0
  106. package/src/lib/margin-layout.bench.ts +61 -0
  107. package/src/lib/margin-layout.ts +0 -7
  108. package/src/lib/markdown-renderer.test.ts +154 -0
  109. package/src/lib/markdown-renderer.ts +177 -0
  110. package/src/lib/mermaid-config.ts +38 -0
  111. package/src/lib/mermaid-renderer.ts +162 -0
  112. package/src/lib/mermaid-worker.ts +60 -0
  113. package/src/lib/positions.ts +31 -24
  114. package/src/lib/shortcut-registry.ts +244 -0
  115. package/src/lib/utils.ts +0 -29
  116. package/src/main.ts +16 -0
  117. package/src/schema.ts +16 -5
  118. package/src/server.ts +355 -91
  119. package/src/stores/app.svelte.ts +231 -0
  120. package/src/stores/locale.svelte.ts +46 -0
  121. package/src/stores/settings.svelte.ts +90 -0
  122. package/src/stores/shortcuts.svelte.ts +104 -0
  123. package/src/stores/ui.svelte.ts +12 -0
  124. package/src/template.ts +104 -0
  125. package/src/test-setup.ts +47 -0
  126. package/svelte.config.js +5 -0
  127. package/tsconfig.json +2 -2
  128. package/vite.config.ts +23 -3
  129. package/vscode-readit/.mcp.json +7 -0
  130. package/vscode-readit/.vscodeignore +7 -0
  131. package/vscode-readit/bun.lock +78 -0
  132. package/vscode-readit/icon.svg +10 -0
  133. package/vscode-readit/package.json +110 -0
  134. package/vscode-readit/src/extension.ts +117 -0
  135. package/vscode-readit/src/server-manager.ts +272 -0
  136. package/vscode-readit/src/webview-provider.ts +204 -0
  137. package/vscode-readit/tsconfig.json +20 -0
  138. package/e2e/fixtures/sample.html +0 -13
  139. package/src/App.tsx +0 -368
  140. package/src/components/ActionsMenu.tsx +0 -91
  141. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  142. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  143. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  144. package/src/components/Header.tsx +0 -54
  145. package/src/components/InlineEditor.tsx +0 -74
  146. package/src/components/MarginNote.tsx +0 -185
  147. package/src/components/MarginNotes.tsx +0 -23
  148. package/src/components/RawModal.tsx +0 -144
  149. package/src/components/ReanchorConfirm.tsx +0 -36
  150. package/src/components/SettingsModal.tsx +0 -232
  151. package/src/components/TabBar.tsx +0 -60
  152. package/src/components/TableOfContents.tsx +0 -108
  153. package/src/components/comments/CommentBadge.tsx +0 -49
  154. package/src/components/comments/CommentInput.tsx +0 -86
  155. package/src/components/comments/CommentListItem.tsx +0 -90
  156. package/src/components/comments/CommentManager.tsx +0 -129
  157. package/src/components/comments/CommentNav.tsx +0 -109
  158. package/src/components/ui/ActionLink.tsx +0 -28
  159. package/src/components/ui/Dialog.tsx +0 -116
  160. package/src/components/ui/DropdownMenu.tsx +0 -158
  161. package/src/contexts/CommentContext.tsx +0 -198
  162. package/src/contexts/LocaleContext.tsx +0 -76
  163. package/src/contexts/PositionsContext.tsx +0 -16
  164. package/src/contexts/SettingsContext.tsx +0 -133
  165. package/src/hooks/useClickOutside.ts +0 -31
  166. package/src/hooks/useCommentNavigation.ts +0 -107
  167. package/src/hooks/useComments.ts +0 -311
  168. package/src/hooks/useDocument.ts +0 -157
  169. package/src/hooks/useScrollSpy.ts +0 -77
  170. package/src/hooks/useTextSelection.ts +0 -86
  171. package/src/lib/highlight/worker.ts +0 -45
  172. package/src/main.tsx +0 -13
  173. 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, "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
+ }