@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.
- package/go/internal/server/comments.go +16 -16
- package/go/internal/server/storage.go +1 -1
- package/go/internal/server/storage_test.go +34 -0
- package/package.json +1 -1
- package/src/App.svelte +33 -24
- package/src/components/DocumentViewer.svelte +16 -1
- package/src/components/MermaidEnhancer.svelte +218 -0
- package/src/components/MermaidModal.svelte +67 -0
- package/src/index.css +105 -0
- package/src/lib/comment-storage.test.ts +63 -1
- package/src/lib/comment-storage.ts +7 -3
- package/src/lib/fetch-or-throw.test.ts +59 -0
- package/src/lib/fetch-or-throw.ts +12 -0
- package/src/lib/i18n/en.ts +8 -0
- package/src/lib/i18n/ja.ts +8 -0
- package/src/lib/i18n/types.ts +8 -0
- package/src/lib/key-lock.test.ts +104 -0
- package/src/lib/key-lock.ts +23 -0
- package/src/lib/markdown-renderer.ts +3 -2
- package/src/server.ts +16 -20
|
@@ -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:([^|]+)\|([
|
|
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
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
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
await fetchOrThrow(
|
|
171
173
|
`/api/comments/${id}?path=${encodeURIComponent(filePath)}`,
|
|
172
|
-
{
|
|
173
|
-
|
|
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
|
-
|
|
195
|
+
await fetchOrThrow(
|
|
192
196
|
`/api/comments?path=${encodeURIComponent(filePath)}`,
|
|
193
|
-
{
|
|
194
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/i18n/en.ts
CHANGED
|
@@ -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
|
};
|
package/src/lib/i18n/ja.ts
CHANGED
|
@@ -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
|
};
|
package/src/lib/i18n/types.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
460
|
+
return errorWithDetail("Failed to re-anchor comment", err);
|
|
465
461
|
}
|
|
466
462
|
}
|
|
467
463
|
|