@peaske7/readit 0.2.1 → 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.
@@ -139,7 +139,7 @@ func (s *Server) createComment(w http.ResponseWriter, r *http.Request) {
139
139
 
140
140
  commentPath, err := CommentPath(path)
141
141
  if err != nil {
142
- writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
142
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to resolve comment path: %v", err))
143
143
  return
144
144
  }
145
145
  cf := CommentFile{
@@ -189,18 +189,18 @@ func (s *Server) updateComment(w http.ResponseWriter, r *http.Request) {
189
189
 
190
190
  commentPath, err := CommentPath(path)
191
191
  if err != nil {
192
- writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
192
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to resolve comment path: %v", err))
193
193
  return
194
194
  }
195
195
  data, err := os.ReadFile(commentPath)
196
196
  if err != nil {
197
- writeError(w, http.StatusNotFound, "comment file not found")
197
+ writeError(w, http.StatusNotFound, fmt.Sprintf("comment file not found: %v", err))
198
198
  return
199
199
  }
200
200
 
201
201
  cf, err := ParseCommentFile(data)
202
202
  if err != nil {
203
- writeError(w, http.StatusInternalServerError, "failed to parse comments")
203
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to parse comments: %v", err))
204
204
  return
205
205
  }
206
206
 
@@ -209,7 +209,7 @@ func (s *Server) updateComment(w http.ResponseWriter, r *http.Request) {
209
209
  cf.Comments[i].Comment = strings.TrimSpace(body.Comment)
210
210
 
211
211
  if err := WriteCommentFile(commentPath, cf); err != nil {
212
- writeError(w, http.StatusInternalServerError, "failed to save")
212
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to save: %v", err))
213
213
  return
214
214
  }
215
215
  s.invalidateCommentCache(path)
@@ -231,18 +231,18 @@ func (s *Server) deleteComment(w http.ResponseWriter, r *http.Request) {
231
231
 
232
232
  commentPath, err := CommentPath(path)
233
233
  if err != nil {
234
- writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
234
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to resolve comment path: %v", err))
235
235
  return
236
236
  }
237
237
  data, err := os.ReadFile(commentPath)
238
238
  if err != nil {
239
- writeError(w, http.StatusNotFound, "comment file not found")
239
+ writeError(w, http.StatusNotFound, fmt.Sprintf("comment file not found: %v", err))
240
240
  return
241
241
  }
242
242
 
243
243
  cf, err := ParseCommentFile(data)
244
244
  if err != nil {
245
- writeError(w, http.StatusInternalServerError, "failed to parse comments")
245
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to parse comments: %v", err))
246
246
  return
247
247
  }
248
248
 
@@ -260,13 +260,13 @@ func (s *Server) deleteComment(w http.ResponseWriter, r *http.Request) {
260
260
 
261
261
  if len(filtered) == 0 {
262
262
  if err := os.Remove(commentPath); err != nil && !os.IsNotExist(err) {
263
- writeError(w, http.StatusInternalServerError, "failed to delete comment file")
263
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to delete comment file: %v", err))
264
264
  return
265
265
  }
266
266
  } else {
267
267
  cf.Comments = filtered
268
268
  if err := WriteCommentFile(commentPath, cf); err != nil {
269
- writeError(w, http.StatusInternalServerError, "failed to save comments")
269
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to save comments: %v", err))
270
270
  return
271
271
  }
272
272
  }
@@ -284,7 +284,7 @@ func (s *Server) deleteAllComments(w http.ResponseWriter, r *http.Request) {
284
284
 
285
285
  commentPath, err := CommentPath(path)
286
286
  if err != nil {
287
- writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
287
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to resolve comment path: %v", err))
288
288
  return
289
289
  }
290
290
  if err := os.Remove(commentPath); err != nil && !os.IsNotExist(err) {
@@ -321,18 +321,18 @@ func (s *Server) reanchorComment(w http.ResponseWriter, r *http.Request) {
321
321
 
322
322
  commentPath, err := CommentPath(path)
323
323
  if err != nil {
324
- writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
324
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to resolve comment path: %v", err))
325
325
  return
326
326
  }
327
327
  data, err := os.ReadFile(commentPath)
328
328
  if err != nil {
329
- writeError(w, http.StatusNotFound, "comment file not found")
329
+ writeError(w, http.StatusNotFound, fmt.Sprintf("comment file not found: %v", err))
330
330
  return
331
331
  }
332
332
 
333
333
  cf, err := ParseCommentFile(data)
334
334
  if err != nil {
335
- writeError(w, http.StatusInternalServerError, "failed to parse comments")
335
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to parse comments: %v", err))
336
336
  return
337
337
  }
338
338
 
@@ -354,7 +354,7 @@ func (s *Server) reanchorComment(w http.ResponseWriter, r *http.Request) {
354
354
 
355
355
  cf.Hash = ComputeHash(state.Content)
356
356
  if err := WriteCommentFile(commentPath, cf); err != nil {
357
- writeError(w, http.StatusInternalServerError, "failed to save")
357
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to save: %v", err))
358
358
  return
359
359
  }
360
360
  s.invalidateCommentCache(path)
@@ -370,7 +370,7 @@ func (s *Server) rawComments(w http.ResponseWriter, r *http.Request) {
370
370
  path := s.resolveFilePath(r)
371
371
  commentPath, err := CommentPath(path)
372
372
  if err != nil {
373
- writeError(w, http.StatusInternalServerError, "failed to resolve comment path")
373
+ writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to resolve comment path: %v", err))
374
374
  return
375
375
  }
376
376
 
@@ -18,7 +18,7 @@ import (
18
18
  var (
19
19
  frontMatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---`)
20
20
  frontMatterStripRe = regexp.MustCompile(`(?s)^---\n.*?\n---\n*`)
21
- commentMetaRe = regexp.MustCompile(`<!--\s*c:([^|]+)\|([^|]+)\|([^>]+)\s*-->`)
21
+ commentMetaRe = regexp.MustCompile(`<!--\s*c:([^|]+)\|([^|>]+?)(?:\|([^>]*?))?\s*-->`)
22
22
  anchorPrefixRe = regexp.MustCompile(`<!--\s*anchor:([A-Za-z0-9+/=]+)\s*-->`)
23
23
  )
24
24
 
@@ -116,3 +116,37 @@ func TestParseAndSerializeRoundTrip(t *testing.T) {
116
116
  }
117
117
  }
118
118
  }
119
+
120
+ func TestParseLegacyTwoFieldMarker(t *testing.T) {
121
+ legacy := []byte(`---
122
+ source: /test.md
123
+ hash: abc
124
+ version: 1
125
+ ---
126
+
127
+ <!-- c:abcd1234|L5 -->
128
+ > selected text
129
+
130
+ a comment body
131
+
132
+ ---
133
+ `)
134
+
135
+ cf, err := ParseCommentFile(legacy)
136
+ if err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ if len(cf.Comments) != 1 {
140
+ t.Fatalf("expected 1 comment from legacy 2-field marker, got %d", len(cf.Comments))
141
+ }
142
+ c := cf.Comments[0]
143
+ if c.ID != "abcd1234" {
144
+ t.Errorf("ID: got %q, want %q", c.ID, "abcd1234")
145
+ }
146
+ if c.LineHint != "L5" {
147
+ t.Errorf("LineHint: got %q, want %q", c.LineHint, "L5")
148
+ }
149
+ if c.CreatedAt != "" {
150
+ t.Errorf("CreatedAt: got %q, want empty string", c.CreatedAt)
151
+ }
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peaske7/readit",
3
- "version": "0.2.1",
3
+ "version": "0.3.0-rc.0",
4
4
  "description": "A CLI tool to review Markdown documents with inline comments",
5
5
  "author": "Jay Shimada <peaske@pm.me>",
6
6
  "license": "MIT",
package/src/App.svelte CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  formatComment,
16
16
  generatePrompt,
17
17
  } from "./lib/export";
18
+ import { fetchOrThrow } from "./lib/fetch-or-throw";
18
19
  import { Positions } from "./lib/positions";
19
20
  import { matchesBinding, ShortcutActions } from "./lib/shortcut-registry";
20
21
  import { AnchorConfidences, type Comment } from "./schema";
@@ -94,7 +95,7 @@ async function addComment(
94
95
  setCommentsError(null, filePath);
95
96
 
96
97
  try {
97
- const response = await fetch(
98
+ const response = await fetchOrThrow(
98
99
  `/api/comments?path=${encodeURIComponent(filePath)}`,
99
100
  {
100
101
  method: "POST",
@@ -106,12 +107,8 @@ async function addComment(
106
107
  endOffset,
107
108
  }),
108
109
  },
110
+ "Failed to add comment",
109
111
  );
110
- if (!response.ok) {
111
- const body = await response.json().catch(() => null);
112
- const detail = body?.error ?? response.statusText;
113
- throw new Error(detail);
114
- }
115
112
  const data = await response.json();
116
113
  const current = app.documents.get(filePath)?.comments ?? [];
117
114
  setComments(
@@ -140,19 +137,23 @@ async function editComment(filePath: string, id: string, newText: string) {
140
137
  filePath,
141
138
  );
142
139
 
140
+ setCommentsError(null, filePath);
143
141
  try {
144
- const response = await fetch(
142
+ await fetchOrThrow(
145
143
  `/api/comments/${id}?path=${encodeURIComponent(filePath)}`,
146
144
  {
147
145
  method: "PUT",
148
146
  headers: { "Content-Type": "application/json" },
149
147
  body: JSON.stringify({ comment: trimmed }),
150
148
  },
149
+ "Failed to update comment",
151
150
  );
152
- if (!response.ok)
153
- throw new Error(`Failed to update comment: ${response.statusText}`);
154
151
  } catch (err) {
155
152
  console.error("Failed to edit comment:", err);
153
+ setCommentsError(
154
+ err instanceof Error ? err.message : "Failed to update comment",
155
+ filePath,
156
+ );
156
157
  setComments(previousComments, filePath);
157
158
  }
158
159
  }
@@ -166,17 +167,19 @@ async function deleteComment(filePath: string, id: string) {
166
167
  filePath,
167
168
  );
168
169
 
170
+ setCommentsError(null, filePath);
169
171
  try {
170
- const response = await fetch(
172
+ await fetchOrThrow(
171
173
  `/api/comments/${id}?path=${encodeURIComponent(filePath)}`,
172
- {
173
- method: "DELETE",
174
- },
174
+ { method: "DELETE" },
175
+ "Failed to delete comment",
175
176
  );
176
- if (!response.ok)
177
- throw new Error(`Failed to delete comment: ${response.statusText}`);
178
177
  } catch (err) {
179
178
  console.error("Failed to delete comment:", err);
179
+ setCommentsError(
180
+ err instanceof Error ? err.message : "Failed to delete comment",
181
+ filePath,
182
+ );
180
183
  setComments(previousComments, filePath);
181
184
  }
182
185
  }
@@ -187,17 +190,19 @@ async function deleteAllComments(filePath: string) {
187
190
 
188
191
  setComments([], filePath);
189
192
 
193
+ setCommentsError(null, filePath);
190
194
  try {
191
- const response = await fetch(
195
+ await fetchOrThrow(
192
196
  `/api/comments?path=${encodeURIComponent(filePath)}`,
193
- {
194
- method: "DELETE",
195
- },
197
+ { method: "DELETE" },
198
+ "Failed to delete all comments",
196
199
  );
197
- if (!response.ok)
198
- throw new Error(`Failed to delete all comments: ${response.statusText}`);
199
200
  } catch (err) {
200
201
  console.error("Failed to delete all comments:", err);
202
+ setCommentsError(
203
+ err instanceof Error ? err.message : "Failed to delete all comments",
204
+ filePath,
205
+ );
201
206
  setComments(previousComments, filePath);
202
207
  }
203
208
  }
@@ -227,17 +232,17 @@ async function reanchorComment(
227
232
  filePath,
228
233
  );
229
234
 
235
+ setCommentsError(null, filePath);
230
236
  try {
231
- const response = await fetch(
237
+ const response = await fetchOrThrow(
232
238
  `/api/comments/${id}/reanchor?path=${encodeURIComponent(filePath)}`,
233
239
  {
234
240
  method: "PUT",
235
241
  headers: { "Content-Type": "application/json" },
236
242
  body: JSON.stringify({ selectedText, startOffset, endOffset }),
237
243
  },
244
+ "Failed to re-anchor comment",
238
245
  );
239
- if (!response.ok)
240
- throw new Error(`Failed to re-anchor comment: ${response.statusText}`);
241
246
  const data = await response.json();
242
247
  const current = app.documents.get(filePath)?.comments ?? [];
243
248
  setComments(
@@ -246,6 +251,10 @@ async function reanchorComment(
246
251
  );
247
252
  } catch (err) {
248
253
  console.error("Failed to re-anchor comment:", err);
254
+ setCommentsError(
255
+ err instanceof Error ? err.message : "Failed to re-anchor comment",
256
+ filePath,
257
+ );
249
258
  setComments(previousComments, filePath);
250
259
  }
251
260
  }
@@ -9,6 +9,7 @@ import type { Positions } from "../lib/positions";
9
9
  import { cn } from "../lib/utils";
10
10
  import { AnchorConfidences, type Comment, FontFamilies } from "../schema";
11
11
  import { settings } from "../stores/settings.svelte";
12
+ import MermaidEnhancer from "./MermaidEnhancer.svelte";
12
13
 
13
14
  let {
14
15
  content,
@@ -40,10 +41,11 @@ let {
40
41
  positions: Positions;
41
42
  } = $props();
42
43
 
43
- let contentEl: HTMLElement | undefined;
44
+ let contentEl: HTMLElement | undefined = $state();
44
45
  let containerEl: HTMLDivElement | undefined = $state();
45
46
  let adapter: Highlighter | null = null;
46
47
  let renderedContent = "";
48
+ let contentVersion = $state(0);
47
49
 
48
50
  let proseClass = $derived(
49
51
  settings.fontFamily === FontFamilies.SANS_SERIF
@@ -74,10 +76,13 @@ async function hydrateMermaid(root: HTMLElement) {
74
76
  );
75
77
  const wrapper = document.createElement("div");
76
78
  wrapper.className = "mermaid-container";
79
+ wrapper.dataset.mermaidSource = encodeURIComponent(code);
80
+ // eslint-disable-next-line -- trusted mermaid render output
77
81
  wrapper.innerHTML = svg;
78
82
  preEl.replaceWith(wrapper);
79
83
  } catch {}
80
84
  }
85
+ contentVersion++;
81
86
  if (isActive) requestAnimationFrame(() => positions.cache());
82
87
  } catch {}
83
88
  });
@@ -132,6 +137,7 @@ onMount(() => {
132
137
  }
133
138
 
134
139
  renderedContent = content;
140
+ contentVersion++;
135
141
 
136
142
  void hydrateMermaid(contentEl!);
137
143
 
@@ -209,6 +215,7 @@ $effect(() => {
209
215
  if (renderedContent !== content) {
210
216
  contentEl.innerHTML = content; // eslint-disable-line -- trusted server content
211
217
  renderedContent = content;
218
+ contentVersion++;
212
219
  void hydrateMermaid(contentEl);
213
220
  }
214
221
  });
@@ -216,3 +223,11 @@ $effect(() => {
216
223
 
217
224
  <div bind:this={containerEl} class="flex-1 min-w-0">
218
225
  </div>
226
+
227
+ <MermaidEnhancer
228
+ root={contentEl}
229
+ {contentVersion}
230
+ notifyContentChanged={() => {
231
+ if (isActive) requestAnimationFrame(() => positions.cache());
232
+ }}
233
+ />
@@ -0,0 +1,218 @@
1
+ <script lang="ts">
2
+ import { Code2, Image as ImageIcon, Maximize2 } from "lucide-svelte";
3
+ import { mount, unmount } from "svelte";
4
+ import { localeState, t } from "../stores/locale.svelte";
5
+ import MermaidModal from "./MermaidModal.svelte";
6
+
7
+ interface Props {
8
+ /** The article element that contains all rendered mermaid containers. */
9
+ root: HTMLElement | undefined;
10
+ /** Bumped whenever document content is re-rendered (rescan triggers). */
11
+ contentVersion: number;
12
+ /** Called after an inline toggle so margin notes / positions can recache. */
13
+ notifyContentChanged?: () => void;
14
+ }
15
+
16
+ let { root, contentVersion, notifyContentChanged }: Props = $props();
17
+
18
+ let modalOpen = $state(false);
19
+ let modalSvg = $state("");
20
+ let modalSource = $state("");
21
+
22
+ const ENHANCED_FLAG = "readitMermaidEnhanced";
23
+
24
+ function decodeSource(container: HTMLElement): string {
25
+ const encoded = container.dataset.mermaidSource ?? "";
26
+ try {
27
+ return decodeURIComponent(encoded);
28
+ } catch {
29
+ return encoded;
30
+ }
31
+ }
32
+
33
+ function openModal(container: HTMLElement) {
34
+ const svgEl = container.querySelector("svg");
35
+ modalSvg = svgEl ? svgEl.outerHTML : "";
36
+ modalSource = decodeSource(container);
37
+ modalOpen = true;
38
+ }
39
+
40
+ function ensureSourceView(container: HTMLElement, source: string): HTMLElement {
41
+ let pre = container.querySelector<HTMLElement>(".mermaid-source-view");
42
+ if (pre) return pre;
43
+ pre = document.createElement("pre");
44
+ pre.className = "mermaid-source-view";
45
+ const codeEl = document.createElement("code");
46
+ codeEl.className = "language-mermaid";
47
+ codeEl.textContent = source;
48
+ pre.appendChild(codeEl);
49
+ pre.style.display = "none";
50
+ // Insert before the toolbar so the toolbar stays the last child.
51
+ const toolbar = container.querySelector(".mermaid-toolbar");
52
+ container.insertBefore(pre, toolbar);
53
+ return pre;
54
+ }
55
+
56
+ function toggleInline(container: HTMLElement, source: string): void {
57
+ const svg = container.querySelector<SVGElement>("svg");
58
+ const pre = ensureSourceView(container, source);
59
+ const showingCode = container.dataset.mermaidView === "code";
60
+ const next = showingCode ? "graph" : "code";
61
+
62
+ if (svg) svg.style.display = next === "code" ? "none" : "";
63
+ pre.style.display = next === "code" ? "block" : "none";
64
+ container.dataset.mermaidView = next;
65
+
66
+ const toolbar = container.querySelector<HTMLElement>(".mermaid-toolbar");
67
+ const toggleBtn = toolbar?.querySelector<HTMLButtonElement>(
68
+ '[data-action="toggle"]',
69
+ );
70
+ toggleBtn?.setAttribute(
71
+ "aria-label",
72
+ next === "code" ? t("mermaid.showDiagram") : t("mermaid.showSource"),
73
+ );
74
+ toggleBtn?.setAttribute("aria-pressed", next === "code" ? "true" : "false");
75
+
76
+ const handle = (
77
+ toolbar as (HTMLElement & { _readitHandle?: ToolbarHandle }) | null
78
+ )?._readitHandle;
79
+ handle?.remountToggleIcon(next);
80
+
81
+ notifyContentChanged?.();
82
+ }
83
+
84
+ interface ToolbarHandle {
85
+ cleanup: () => void;
86
+ remountToggleIcon: (view: "graph" | "code") => void;
87
+ }
88
+
89
+ function mountToggleIcon(target: HTMLElement, view: "graph" | "code") {
90
+ // Show "code" icon when graph is visible (click to see code), and vice versa.
91
+ const Icon = view === "graph" ? Code2 : ImageIcon;
92
+ return mount(Icon, { target, props: { size: 14 } });
93
+ }
94
+
95
+ function buildToolbar(container: HTMLElement): HTMLElement {
96
+ const toolbar = document.createElement("div");
97
+ toolbar.className = "mermaid-toolbar";
98
+ toolbar.setAttribute("contenteditable", "false");
99
+
100
+ const toggleBtn = document.createElement("button");
101
+ toggleBtn.type = "button";
102
+ toggleBtn.dataset.action = "toggle";
103
+ toggleBtn.setAttribute("aria-label", t("mermaid.showSource"));
104
+ toggleBtn.setAttribute("aria-pressed", "false");
105
+
106
+ const expandBtn = document.createElement("button");
107
+ expandBtn.type = "button";
108
+ expandBtn.dataset.action = "expand";
109
+ expandBtn.setAttribute("aria-label", t("mermaid.expand"));
110
+
111
+ toolbar.appendChild(toggleBtn);
112
+ toolbar.appendChild(expandBtn);
113
+
114
+ let toggleIcon = mountToggleIcon(toggleBtn, "graph");
115
+ const expandIcon = mount(Maximize2, {
116
+ target: expandBtn,
117
+ props: { size: 14 },
118
+ });
119
+
120
+ toggleBtn.addEventListener("click", (e) => {
121
+ e.preventDefault();
122
+ e.stopPropagation();
123
+ toggleInline(container, decodeSource(container));
124
+ });
125
+
126
+ expandBtn.addEventListener("click", (e) => {
127
+ e.preventDefault();
128
+ e.stopPropagation();
129
+ openModal(container);
130
+ });
131
+
132
+ const handle: ToolbarHandle = {
133
+ cleanup: () => {
134
+ void unmount(toggleIcon);
135
+ void unmount(expandIcon);
136
+ },
137
+ remountToggleIcon: (view) => {
138
+ void unmount(toggleIcon);
139
+ toggleIcon = mountToggleIcon(toggleBtn, view);
140
+ },
141
+ };
142
+ (toolbar as HTMLElement & { _readitHandle?: ToolbarHandle })._readitHandle =
143
+ handle;
144
+
145
+ return toolbar;
146
+ }
147
+
148
+ function enhance(target: HTMLElement) {
149
+ const containers = target.querySelectorAll<HTMLElement>(
150
+ ".mermaid-container[data-mermaid-source]",
151
+ );
152
+
153
+ for (const container of containers) {
154
+ if (container.dataset[ENHANCED_FLAG] === "true") continue;
155
+ container.dataset[ENHANCED_FLAG] = "true";
156
+
157
+ // Ensure the container can host the absolutely-positioned toolbar.
158
+ container.style.position = "relative";
159
+
160
+ const toolbar = buildToolbar(container);
161
+ container.appendChild(toolbar);
162
+ }
163
+ }
164
+
165
+ function cleanup(target: HTMLElement) {
166
+ const toolbars = target.querySelectorAll<HTMLElement>(".mermaid-toolbar");
167
+ for (const tb of toolbars) {
168
+ (
169
+ tb as HTMLElement & { _readitHandle?: ToolbarHandle }
170
+ )._readitHandle?.cleanup();
171
+ tb.remove();
172
+ }
173
+ const enhanced = target.querySelectorAll<HTMLElement>(
174
+ ".mermaid-container[data-readit-mermaid-enhanced]",
175
+ );
176
+ for (const c of enhanced) {
177
+ delete c.dataset[ENHANCED_FLAG];
178
+ }
179
+ }
180
+
181
+ $effect(() => {
182
+ if (!root) return;
183
+ // Re-scan whenever contentVersion changes; declared so Svelte tracks it.
184
+ void contentVersion;
185
+ enhance(root);
186
+ return () => {
187
+ if (root) cleanup(root);
188
+ };
189
+ });
190
+
191
+ // Refresh aria-labels on existing imperative toolbars when locale changes.
192
+ $effect(() => {
193
+ if (!root) return;
194
+ void localeState.locale;
195
+ const toolbars = root.querySelectorAll<HTMLElement>(".mermaid-toolbar");
196
+ for (const tb of toolbars) {
197
+ const container = tb.closest<HTMLElement>(".mermaid-container");
198
+ const showingCode = container?.dataset.mermaidView === "code";
199
+ tb.querySelector<HTMLButtonElement>('[data-action="toggle"]')?.setAttribute(
200
+ "aria-label",
201
+ showingCode ? t("mermaid.showDiagram") : t("mermaid.showSource"),
202
+ );
203
+ tb.querySelector<HTMLButtonElement>('[data-action="expand"]')?.setAttribute(
204
+ "aria-label",
205
+ t("mermaid.expand"),
206
+ );
207
+ }
208
+ });
209
+ </script>
210
+
211
+ <MermaidModal
212
+ bind:open={modalOpen}
213
+ svg={modalSvg}
214
+ source={modalSource}
215
+ onclose={() => {
216
+ modalOpen = false;
217
+ }}
218
+ />
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import { Code2, Image as ImageIcon } from "lucide-svelte";
3
+ import { t } from "../stores/locale.svelte";
4
+ import Dialog from "./ui/Dialog.svelte";
5
+
6
+ interface Props {
7
+ open: boolean;
8
+ svg: string;
9
+ source: string;
10
+ onclose: () => void;
11
+ }
12
+
13
+ let { open = $bindable(false), svg, source, onclose }: Props = $props();
14
+
15
+ type View = "graph" | "code";
16
+ let view = $state<View>("graph");
17
+
18
+ // Reset to graph view each time the modal opens so the user always
19
+ // lands on the diagram first.
20
+ $effect(() => {
21
+ if (open) view = "graph";
22
+ });
23
+ </script>
24
+
25
+ <Dialog
26
+ bind:open
27
+ {onclose}
28
+ contentClass="w-[90vw] h-[90vh] max-w-[90vw]"
29
+ >
30
+ {#snippet header()}
31
+ {t("mermaid.modalTitle")}
32
+ {/snippet}
33
+
34
+ {#snippet headerActions()}
35
+ <div class="flex items-center gap-1 mr-8">
36
+ <button
37
+ type="button"
38
+ onclick={() => (view = "graph")}
39
+ aria-pressed={view === "graph"}
40
+ class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 aria-pressed:bg-zinc-100 dark:aria-pressed:bg-zinc-800 aria-pressed:text-zinc-900 dark:aria-pressed:text-zinc-100"
41
+ >
42
+ <ImageIcon class="w-3.5 h-3.5" />
43
+ {t("mermaid.viewGraph")}
44
+ </button>
45
+ <button
46
+ type="button"
47
+ onclick={() => (view = "code")}
48
+ aria-pressed={view === "code"}
49
+ class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 aria-pressed:bg-zinc-100 dark:aria-pressed:bg-zinc-800 aria-pressed:text-zinc-900 dark:aria-pressed:text-zinc-100"
50
+ >
51
+ <Code2 class="w-3.5 h-3.5" />
52
+ {t("mermaid.viewCode")}
53
+ </button>
54
+ </div>
55
+ {/snippet}
56
+
57
+ <div class="w-full h-full overflow-auto flex items-center justify-center">
58
+ {#if view === "graph"}
59
+ <!-- eslint-disable-next-line -- trusted mermaid render output -->
60
+ <div class="mermaid-container mermaid-modal-graph">{@html svg}</div>
61
+ {:else}
62
+ <pre
63
+ class="w-full h-full m-0 p-4 text-xs leading-relaxed font-mono text-zinc-800 dark:text-zinc-200 bg-zinc-50 dark:bg-zinc-950 rounded-lg overflow-auto whitespace-pre"
64
+ >{source}</pre>
65
+ {/if}
66
+ </div>
67
+ </Dialog>
package/src/index.css CHANGED
@@ -734,6 +734,111 @@ html {
734
734
  }
735
735
  }
736
736
 
737
+ /* Interactive toolbar (hover-revealed) */
738
+ .mermaid-toolbar {
739
+ position: absolute;
740
+ top: 0.5rem;
741
+ right: 0.5rem;
742
+ display: flex;
743
+ gap: 0.25rem;
744
+ padding: 0.25rem;
745
+ border-radius: 0.5rem;
746
+ background: rgba(255, 255, 255, 0.85);
747
+ backdrop-filter: blur(4px);
748
+ border: 1px solid rgba(228, 228, 231, 0.6);
749
+ opacity: 0;
750
+ transition: opacity 120ms ease-out;
751
+ pointer-events: none;
752
+ z-index: 5;
753
+ }
754
+
755
+ .dark .mermaid-toolbar {
756
+ background: rgba(24, 24, 27, 0.85);
757
+ border-color: rgba(63, 63, 70, 0.6);
758
+ }
759
+
760
+ .mermaid-container:hover .mermaid-toolbar,
761
+ .mermaid-toolbar:focus-within {
762
+ opacity: 1;
763
+ pointer-events: auto;
764
+ }
765
+
766
+ .mermaid-toolbar button {
767
+ display: inline-flex;
768
+ align-items: center;
769
+ justify-content: center;
770
+ width: 1.75rem;
771
+ height: 1.75rem;
772
+ border-radius: 0.375rem;
773
+ color: rgb(82, 82, 91);
774
+ background: transparent;
775
+ border: 0;
776
+ cursor: pointer;
777
+ transition:
778
+ background 120ms ease-out,
779
+ color 120ms ease-out;
780
+ }
781
+
782
+ .mermaid-toolbar button:hover {
783
+ background: rgba(228, 228, 231, 0.8);
784
+ color: rgb(24, 24, 27);
785
+ }
786
+
787
+ .dark .mermaid-toolbar button {
788
+ color: rgb(161, 161, 170);
789
+ }
790
+
791
+ .dark .mermaid-toolbar button:hover {
792
+ background: rgba(63, 63, 70, 0.8);
793
+ color: rgb(244, 244, 245);
794
+ }
795
+
796
+ /* Inline source view shown when toggled to code mode */
797
+ .mermaid-source-view {
798
+ margin: 0;
799
+ padding: 1rem;
800
+ border-radius: 0.5rem;
801
+ background: rgb(244, 244, 245);
802
+ color: rgb(39, 39, 42);
803
+ font-size: 0.8125rem;
804
+ line-height: 1.5;
805
+ font-family:
806
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
807
+ "Courier New", monospace;
808
+ overflow: auto;
809
+ white-space: pre;
810
+ }
811
+
812
+ .dark .mermaid-source-view {
813
+ background: rgb(24, 24, 27);
814
+ color: rgb(228, 228, 231);
815
+ }
816
+
817
+ .mermaid-source-view code {
818
+ background: transparent;
819
+ padding: 0;
820
+ font-family: inherit;
821
+ font-size: inherit;
822
+ color: inherit;
823
+ }
824
+
825
+ /* Modal-mode mermaid graph — let it scale up */
826
+ .mermaid-modal-graph {
827
+ margin: 0;
828
+ padding: 0;
829
+ width: 100%;
830
+ display: flex;
831
+ align-items: center;
832
+ justify-content: center;
833
+ }
834
+
835
+ .mermaid-modal-graph > svg {
836
+ max-width: 100%;
837
+ max-height: 80vh;
838
+ width: auto;
839
+ height: auto;
840
+ }
841
+
737
842
  /* ========================================
738
843
  Floating TOC Panel
739
844
  ======================================== */
@@ -328,13 +328,14 @@ describe("serializeComments", () => {
328
328
  selectedText: "selected text",
329
329
  comment: "My comment",
330
330
  lineHint: "L42",
331
+ createdAt: "2026-05-09T00:00:00Z",
331
332
  startOffset: 100,
332
333
  endOffset: 113,
333
334
  },
334
335
  ],
335
336
  };
336
337
  const result = serializeComments(file);
337
- expect(result).toContain("<!-- c:12345678|L42 -->");
338
+ expect(result).toContain("<!-- c:12345678|L42|2026-05-09T00:00:00Z -->");
338
339
  expect(result).toContain("> selected text");
339
340
  expect(result).toContain("My comment");
340
341
  });
@@ -591,6 +592,67 @@ describe("truncateSelection", () => {
591
592
  });
592
593
  });
593
594
 
595
+ describe("parseCommentFile cross-runtime format", () => {
596
+ it("parses 2-field marker (legacy TS-written files)", () => {
597
+ const content = `---
598
+ source: /test.md
599
+ hash: abc
600
+ version: 1
601
+ ---
602
+
603
+ <!-- c:abcd1234|L5 -->
604
+ > selected text
605
+ some comment
606
+
607
+ ---
608
+ `;
609
+ const parsed = parseCommentFile(content);
610
+ expect(parsed.comments).toHaveLength(1);
611
+ expect(parsed.comments[0].id).toBe("abcd1234");
612
+ expect(parsed.comments[0].lineHint).toBe("L5");
613
+ expect(parsed.comments[0].createdAt).toBeUndefined();
614
+ });
615
+
616
+ it("parses 3-field marker (Go-written files)", () => {
617
+ const content = `---
618
+ source: /test.md
619
+ hash: abc
620
+ version: 1
621
+ ---
622
+
623
+ <!-- c:abcd1234|L5|2026-05-09T12:34:56.789Z -->
624
+ > selected text
625
+ some comment
626
+
627
+ ---
628
+ `;
629
+ const parsed = parseCommentFile(content);
630
+ expect(parsed.comments).toHaveLength(1);
631
+ expect(parsed.comments[0].createdAt).toBe("2026-05-09T12:34:56.789Z");
632
+ });
633
+
634
+ it("serializeComments writes 3-field marker", () => {
635
+ const file: CommentFile = {
636
+ source: "/test.md",
637
+ hash: "abc",
638
+ version: 1,
639
+ comments: [
640
+ {
641
+ id: "abcd1234",
642
+ selectedText: "selected",
643
+ comment: "body",
644
+ lineHint: "L1",
645
+ createdAt: "2026-05-09T00:00:00Z",
646
+ startOffset: 0,
647
+ endOffset: 8,
648
+ },
649
+ ],
650
+ };
651
+ const serialized = serializeComments(file);
652
+ expect(serialized).toContain("<!-- c:abcd1234|L1|2026-05-09T00:00:00Z -->");
653
+ });
654
+ });
655
+
594
656
  describe("parseCommentFile version check", () => {
595
657
  it("accepts current version", () => {
596
658
  const content = `---
@@ -116,13 +116,14 @@ export function parseCommentFile(content: string): CommentFile {
116
116
 
117
117
  function parseCommentBlock(block: string): Comment | undefined {
118
118
  const metadataMatch = block.match(
119
- /<!--\s*c:([^|]+)\|([^|>\s]+)(?:\|[^>]*)?\s*-->/,
119
+ /<!--\s*c:([^|]+)\|([^|>\s]+)(?:\|([^>]*))?\s*-->/,
120
120
  );
121
121
  if (!metadataMatch) {
122
122
  return undefined;
123
123
  }
124
124
 
125
- const [, id, lineHint] = metadataMatch;
125
+ const [, id, lineHint, createdAtRaw] = metadataMatch;
126
+ const createdAt = createdAtRaw?.trim() || undefined;
126
127
 
127
128
  const anchorMatch = block.match(/<!--\s*anchor:(.+?)\s*-->/);
128
129
  const anchorPrefix = anchorMatch ? anchorMatch[1] : undefined;
@@ -147,6 +148,7 @@ function parseCommentBlock(block: string): Comment | undefined {
147
148
  selectedText,
148
149
  comment: commentBody,
149
150
  lineHint,
151
+ createdAt,
150
152
  anchorPrefix,
151
153
  startOffset: 0,
152
154
  endOffset: 0,
@@ -177,7 +179,8 @@ function serializeComment(comment: Comment): string {
177
179
  const lines: string[] = [];
178
180
 
179
181
  const lineHint = comment.lineHint || "L0";
180
- lines.push(`<!-- c:${comment.id}|${lineHint} -->`);
182
+ const createdAt = comment.createdAt || new Date().toISOString();
183
+ lines.push(`<!-- c:${comment.id}|${lineHint}|${createdAt} -->`);
181
184
 
182
185
  if (comment.anchorPrefix) {
183
186
  lines.push(`<!-- anchor:${comment.anchorPrefix} -->`);
@@ -215,6 +218,7 @@ export function createComment(
215
218
  startOffset,
216
219
  endOffset,
217
220
  lineHint,
221
+ createdAt: new Date().toISOString(),
218
222
  anchorPrefix: needsTruncation
219
223
  ? selectedText.slice(0, ANCHOR_PREFIX_LENGTH)
220
224
  : undefined,
@@ -0,0 +1,59 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { fetchOrThrow } from "./fetch-or-throw";
3
+
4
+ function mockFetch(response: Response) {
5
+ return vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
6
+ }
7
+
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ describe("fetchOrThrow", () => {
13
+ it("returns response on 2xx", async () => {
14
+ mockFetch(new Response("ok", { status: 200 }));
15
+ const res = await fetchOrThrow("/api", {}, "fallback");
16
+ expect(res.ok).toBe(true);
17
+ });
18
+
19
+ it("throws body.error when response is not ok and body has error field", async () => {
20
+ mockFetch(
21
+ new Response(JSON.stringify({ error: "EACCES: permission denied" }), {
22
+ status: 500,
23
+ headers: { "Content-Type": "application/json" },
24
+ }),
25
+ );
26
+ await expect(fetchOrThrow("/api", {}, "fallback")).rejects.toThrow(
27
+ "EACCES: permission denied",
28
+ );
29
+ });
30
+
31
+ it("falls back to statusText when body is not JSON", async () => {
32
+ mockFetch(
33
+ new Response("not json", { status: 500, statusText: "Server Error" }),
34
+ );
35
+ await expect(fetchOrThrow("/api", {}, "fallback")).rejects.toThrow(
36
+ "Server Error",
37
+ );
38
+ });
39
+
40
+ it("falls back to fallback message when statusText is empty", async () => {
41
+ mockFetch(new Response("", { status: 500, statusText: "" }));
42
+ await expect(fetchOrThrow("/api", {}, "fallback message")).rejects.toThrow(
43
+ "fallback message",
44
+ );
45
+ });
46
+
47
+ it("falls back to statusText when body is JSON but lacks error field", async () => {
48
+ mockFetch(
49
+ new Response(JSON.stringify({ ok: false }), {
50
+ status: 500,
51
+ statusText: "Internal Error",
52
+ headers: { "Content-Type": "application/json" },
53
+ }),
54
+ );
55
+ await expect(fetchOrThrow("/api", {}, "fallback")).rejects.toThrow(
56
+ "Internal Error",
57
+ );
58
+ });
59
+ });
@@ -0,0 +1,12 @@
1
+ export async function fetchOrThrow(
2
+ url: string,
3
+ init: RequestInit,
4
+ fallback: string,
5
+ ): Promise<Response> {
6
+ const response = await fetch(url, init);
7
+ if (!response.ok) {
8
+ const body = await response.json().catch(() => null);
9
+ throw new Error(body?.error || response.statusText || fallback);
10
+ }
11
+ return response;
12
+ }
@@ -113,4 +113,12 @@ export const en: Translations = {
113
113
  "Copy selected text with context for LLM",
114
114
  "shortcut.clearSelection.label": "Clear Selection",
115
115
  "shortcut.clearSelection.description": "Clear text selection",
116
+
117
+ // Mermaid diagram
118
+ "mermaid.modalTitle": "Mermaid diagram",
119
+ "mermaid.viewGraph": "Graph",
120
+ "mermaid.viewCode": "Code",
121
+ "mermaid.showSource": "Show source",
122
+ "mermaid.showDiagram": "Show diagram",
123
+ "mermaid.expand": "Expand diagram",
116
124
  };
@@ -115,4 +115,12 @@ export const ja: Translations = {
115
115
  "選択テキストをLLMコンテキスト付きでコピー",
116
116
  "shortcut.clearSelection.label": "選択を解除",
117
117
  "shortcut.clearSelection.description": "テキスト選択を解除",
118
+
119
+ // Mermaid diagram
120
+ "mermaid.modalTitle": "Mermaid ダイアグラム",
121
+ "mermaid.viewGraph": "図",
122
+ "mermaid.viewCode": "コード",
123
+ "mermaid.showSource": "ソースを表示",
124
+ "mermaid.showDiagram": "図を表示",
125
+ "mermaid.expand": "拡大表示",
118
126
  };
@@ -117,6 +117,14 @@ export interface Translations {
117
117
  "shortcut.copySelectionLLM.description": string;
118
118
  "shortcut.clearSelection.label": string;
119
119
  "shortcut.clearSelection.description": string;
120
+
121
+ // Mermaid diagram
122
+ "mermaid.modalTitle": string;
123
+ "mermaid.viewGraph": string;
124
+ "mermaid.viewCode": string;
125
+ "mermaid.showSource": string;
126
+ "mermaid.showDiagram": string;
127
+ "mermaid.expand": string;
120
128
  }
121
129
 
122
130
  export type TranslationKey = keyof Translations;
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createKeyLock } from "./key-lock";
3
+
4
+ function deferred<T>(): {
5
+ promise: Promise<T>;
6
+ resolve: (v: T) => void;
7
+ reject: (e: unknown) => void;
8
+ } {
9
+ let resolve!: (v: T) => void;
10
+ let reject!: (e: unknown) => void;
11
+ const promise = new Promise<T>((res, rej) => {
12
+ resolve = res;
13
+ reject = rej;
14
+ });
15
+ return { promise, resolve, reject };
16
+ }
17
+
18
+ describe("createKeyLock", () => {
19
+ it("serializes operations on the same key", async () => {
20
+ const withLock = createKeyLock("serial");
21
+ const order: number[] = [];
22
+ const a = deferred<void>();
23
+ const b = deferred<void>();
24
+
25
+ const p1 = withLock("k", async () => {
26
+ await a.promise;
27
+ order.push(1);
28
+ });
29
+ const p2 = withLock("k", async () => {
30
+ await b.promise;
31
+ order.push(2);
32
+ });
33
+
34
+ // p2 must NOT have started yet because p1 hasn't resolved
35
+ b.resolve();
36
+ await Promise.resolve();
37
+ await Promise.resolve();
38
+ expect(order).toEqual([]);
39
+
40
+ a.resolve();
41
+ await p1;
42
+ await p2;
43
+ expect(order).toEqual([1, 2]);
44
+ });
45
+
46
+ it("runs different keys concurrently", async () => {
47
+ const withLock = createKeyLock("concurrent");
48
+ const order: string[] = [];
49
+ const a = deferred<void>();
50
+
51
+ const p1 = withLock("alpha", async () => {
52
+ await a.promise;
53
+ order.push("alpha");
54
+ });
55
+ const p2 = withLock("beta", async () => {
56
+ order.push("beta");
57
+ });
58
+
59
+ await p2;
60
+ expect(order).toEqual(["beta"]);
61
+
62
+ a.resolve();
63
+ await p1;
64
+ expect(order).toEqual(["beta", "alpha"]);
65
+ });
66
+
67
+ it("survives a thrown error in a previous holder", async () => {
68
+ const withLock = createKeyLock("error-recovery");
69
+ const p1 = withLock("k", async () => {
70
+ throw new Error("boom");
71
+ });
72
+ await expect(p1).rejects.toThrow("boom");
73
+
74
+ const p2 = withLock("k", async () => 42);
75
+ await expect(p2).resolves.toBe(42);
76
+ });
77
+
78
+ it("preserves resolved return values", async () => {
79
+ const withLock = createKeyLock("return-value");
80
+ const v = await withLock("k", async () => "value");
81
+ expect(v).toBe("value");
82
+ });
83
+
84
+ it("namespaces are independent", async () => {
85
+ const a = createKeyLock("ns-a");
86
+ const b = createKeyLock("ns-b");
87
+ const order: string[] = [];
88
+ const block = deferred<void>();
89
+
90
+ const pa = a("shared-key", async () => {
91
+ await block.promise;
92
+ order.push("a");
93
+ });
94
+ const pb = b("shared-key", async () => {
95
+ order.push("b");
96
+ });
97
+
98
+ await pb;
99
+ expect(order).toEqual(["b"]);
100
+ block.resolve();
101
+ await pa;
102
+ expect(order).toEqual(["b", "a"]);
103
+ });
104
+ });
@@ -0,0 +1,23 @@
1
+ const locks = new Map<string, Map<string, Promise<unknown>>>();
2
+
3
+ function namespace(name: string): Map<string, Promise<unknown>> {
4
+ let n = locks.get(name);
5
+ if (!n) {
6
+ n = new Map();
7
+ locks.set(name, n);
8
+ }
9
+ return n;
10
+ }
11
+
12
+ export function createKeyLock(name: string) {
13
+ const map = namespace(name);
14
+ return function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
15
+ const prev = map.get(key) ?? Promise.resolve();
16
+ const next = prev.catch(() => {}).then(fn);
17
+ map.set(
18
+ key,
19
+ next.catch(() => {}),
20
+ );
21
+ return next;
22
+ };
23
+ }
@@ -150,8 +150,9 @@ async function replaceMermaidBlocks(html: string): Promise<string> {
150
150
  for (let i = matches.length - 1; i >= 0; i--) {
151
151
  const svg = svgs[i];
152
152
  if (svg !== null) {
153
- const { fullMatch, index } = matches[i];
154
- const replacement = `<div class="mermaid-container">${svg}</div>`;
153
+ const { fullMatch, index, code } = matches[i];
154
+ const encodedSource = encodeURIComponent(code);
155
+ const replacement = `<div class="mermaid-container" data-mermaid-source="${encodedSource}">${svg}</div>`;
155
156
  html =
156
157
  html.slice(0, index) +
157
158
  replacement +
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "./lib/comment-storage.js";
16
16
  import { findTextPosition } from "./lib/highlight/resolver.js";
17
17
  import { extractTextFromHtml } from "./lib/html-text.js";
18
+ import { createKeyLock } from "./lib/key-lock.js";
18
19
  import { getShiki, renderMarkdown } from "./lib/markdown-renderer.js";
19
20
  import { disposeMermaidWorker } from "./lib/mermaid-renderer.js";
20
21
  import { isMarkdownFile } from "./lib/utils.js";
@@ -61,20 +62,7 @@ function invalidateResolvedComments(filePath: string): void {
61
62
  resolvedCommentsCache.delete(filePath);
62
63
  }
63
64
 
64
- const commentWriteLocks = new Map<string, Promise<unknown>>();
65
-
66
- function withCommentLock<T>(
67
- filePath: string,
68
- fn: () => Promise<T>,
69
- ): Promise<T> {
70
- const prev = commentWriteLocks.get(filePath) ?? Promise.resolve();
71
- const next = prev.catch(() => {}).then(fn);
72
- commentWriteLocks.set(
73
- filePath,
74
- next.catch(() => {}),
75
- );
76
- return next;
77
- }
65
+ const withCommentLock = createKeyLock("comments");
78
66
 
79
67
  async function canonicalPath(filePath: string): Promise<string> {
80
68
  return fs.realpath(path.resolve(filePath));
@@ -261,6 +249,15 @@ function errorResponse(message: string, status: number): Response {
261
249
  return Response.json({ error: message }, { status });
262
250
  }
263
251
 
252
+ function errorWithDetail(
253
+ message: string,
254
+ err: unknown,
255
+ status = 500,
256
+ ): Response {
257
+ const detail = err instanceof Error ? err.message : String(err);
258
+ return errorResponse(`${message}: ${detail}`, status);
259
+ }
260
+
264
261
  interface RouteContext {
265
262
  filePath: string;
266
263
  getCurrentContent: () => Promise<string>;
@@ -323,8 +320,7 @@ async function addComment(ctx: RouteContext, req: Request): Promise<Response> {
323
320
  return json({ comment: newComment }, 201);
324
321
  } catch (err) {
325
322
  console.error("Failed to add comment:", err);
326
- const detail = err instanceof Error ? err.message : String(err);
327
- return errorResponse(`Failed to add comment: ${detail}`, 500);
323
+ return errorWithDetail("Failed to add comment", err);
328
324
  }
329
325
  }
330
326
 
@@ -359,7 +355,7 @@ async function updateComment(
359
355
  return json({ comment: result });
360
356
  } catch (err) {
361
357
  console.error("Failed to update comment:", err);
362
- return errorResponse("Failed to update comment", 500);
358
+ return errorWithDetail("Failed to update comment", err);
363
359
  }
364
360
  }
365
361
 
@@ -389,7 +385,7 @@ async function deleteComment(ctx: RouteContext, id: string): Promise<Response> {
389
385
  return json({ success: true });
390
386
  } catch (err) {
391
387
  console.error("Failed to delete comment:", err);
392
- return errorResponse("Failed to delete comment", 500);
388
+ return errorWithDetail("Failed to delete comment", err);
393
389
  }
394
390
  }
395
391
 
@@ -399,7 +395,7 @@ async function clearComments(ctx: RouteContext): Promise<Response> {
399
395
  return json({ success: true });
400
396
  } catch (err) {
401
397
  console.error("Failed to clear comments:", err);
402
- return errorResponse("Failed to clear comments", 500);
398
+ return errorWithDetail("Failed to clear comments", err);
403
399
  }
404
400
  }
405
401
 
@@ -461,7 +457,7 @@ async function reanchorComment(
461
457
  return json({ comment: result });
462
458
  } catch (err) {
463
459
  console.error("Failed to re-anchor comment:", err);
464
- return errorResponse("Failed to re-anchor comment", 500);
460
+ return errorWithDetail("Failed to re-anchor comment", err);
465
461
  }
466
462
  }
467
463