@peaske7/readit 0.1.6 → 0.1.7
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/biome.json +1 -1
- package/bun.lock +86 -72
- package/package.json +12 -11
- package/src/App.tsx +23 -6
- package/src/cli/index.ts +167 -19
- package/src/components/ActionsMenu.tsx +12 -10
- package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
- package/src/components/DocumentViewer/DocumentViewer.tsx +6 -6
- package/src/components/DocumentViewer/InlineCode.tsx +60 -0
- package/src/components/FloatingTOC.tsx +4 -2
- package/src/components/Header.tsx +3 -1
- package/src/components/InlineEditor.tsx +4 -2
- package/src/components/MarginNote.tsx +17 -8
- package/src/components/RawModal.tsx +9 -7
- package/src/components/ReanchorConfirm.tsx +6 -3
- package/src/components/SettingsModal.tsx +112 -23
- package/src/components/ShortcutCapture.tsx +4 -1
- package/src/components/ShortcutList.tsx +50 -9
- package/src/components/comments/CommentBadge.tsx +7 -1
- package/src/components/comments/CommentInput.tsx +13 -18
- package/src/components/comments/CommentListItem.tsx +15 -5
- package/src/components/comments/CommentManager.tsx +14 -7
- package/src/components/comments/CommentNav.tsx +8 -3
- package/src/contexts/CommentContext.tsx +16 -9
- package/src/contexts/LayoutContext.tsx +17 -5
- package/src/contexts/LocaleContext.tsx +35 -0
- package/src/hooks/useClipboard.ts +11 -8
- package/src/hooks/useDocument.ts +33 -18
- package/src/hooks/useEditorScheme.ts +51 -0
- package/src/hooks/useFontPreference.ts +5 -22
- package/src/hooks/useKeybindings.ts +6 -18
- package/src/hooks/useLocalePreference.ts +42 -0
- package/src/index.css +87 -26
- package/src/lib/editor-links.ts +59 -0
- package/src/lib/highlight/dom.ts +126 -54
- package/src/lib/highlight/highlighter.ts +10 -10
- package/src/lib/i18n/completeness.test.ts +51 -0
- package/src/lib/i18n/en.ts +139 -0
- package/src/lib/i18n/index.ts +3 -0
- package/src/lib/i18n/ja.ts +141 -0
- package/src/lib/i18n/translations.test.ts +39 -0
- package/src/lib/i18n/translations.ts +27 -0
- package/src/lib/i18n/types.ts +145 -0
- package/src/lib/shortcut-registry.ts +1 -1
- package/src/main.tsx +4 -1
- package/src/server/index.ts +160 -117
- package/src/store/index.test.ts +22 -0
- package/src/store/index.ts +24 -4
- package/src/types/index.ts +12 -0
package/src/cli/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
lstatSync,
|
|
6
6
|
readdirSync,
|
|
7
7
|
readFileSync,
|
|
8
|
+
realpathSync,
|
|
8
9
|
statSync,
|
|
9
10
|
} from "node:fs";
|
|
10
11
|
import * as fs from "node:fs/promises";
|
|
@@ -116,7 +117,6 @@ function findReviewableFiles(dir: string): FileEntry[] {
|
|
|
116
117
|
const type = getFileType(entry);
|
|
117
118
|
if (type) {
|
|
118
119
|
results.push({
|
|
119
|
-
content: readFileSync(fullPath, "utf-8"),
|
|
120
120
|
type,
|
|
121
121
|
filePath: fullPath,
|
|
122
122
|
});
|
|
@@ -145,13 +145,15 @@ function resolveFiles(args: string[]): FileEntry[] {
|
|
|
145
145
|
const files: FileEntry[] = [];
|
|
146
146
|
|
|
147
147
|
for (const arg of args) {
|
|
148
|
-
const
|
|
148
|
+
const inputPath = resolve(process.cwd(), arg);
|
|
149
149
|
|
|
150
|
-
if (!existsSync(
|
|
151
|
-
console.error(`error: not found: ${
|
|
150
|
+
if (!existsSync(inputPath)) {
|
|
151
|
+
console.error(`error: not found: ${inputPath}`);
|
|
152
152
|
process.exit(1);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
const filePath = realpathSync(inputPath);
|
|
156
|
+
|
|
155
157
|
const stat = statSync(filePath);
|
|
156
158
|
|
|
157
159
|
if (stat.isDirectory()) {
|
|
@@ -175,7 +177,6 @@ function resolveFiles(args: string[]): FileEntry[] {
|
|
|
175
177
|
|
|
176
178
|
seen.add(filePath);
|
|
177
179
|
files.push({
|
|
178
|
-
content: readFileSync(filePath, "utf-8"),
|
|
179
180
|
type,
|
|
180
181
|
filePath,
|
|
181
182
|
});
|
|
@@ -185,6 +186,114 @@ function resolveFiles(args: string[]): FileEntry[] {
|
|
|
185
186
|
return files;
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
// ─── Onboarding ──────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
const SETTINGS_PATH = join(os.homedir(), ".readit", "settings.json");
|
|
192
|
+
|
|
193
|
+
function isOnboarded(): boolean {
|
|
194
|
+
try {
|
|
195
|
+
const content = readFileSync(SETTINGS_PATH, "utf-8");
|
|
196
|
+
const settings = JSON.parse(content);
|
|
197
|
+
return settings.onboarded === true;
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function markOnboarded(): Promise<void> {
|
|
204
|
+
let settings: Record<string, unknown> = {};
|
|
205
|
+
try {
|
|
206
|
+
const content = readFileSync(SETTINGS_PATH, "utf-8");
|
|
207
|
+
settings = JSON.parse(content);
|
|
208
|
+
} catch {
|
|
209
|
+
// No existing settings
|
|
210
|
+
}
|
|
211
|
+
settings.onboarded = true;
|
|
212
|
+
const dir = join(os.homedir(), ".readit");
|
|
213
|
+
await fs.mkdir(dir, { recursive: true });
|
|
214
|
+
await fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const WELCOME_CONTENT = `# Welcome to readit
|
|
218
|
+
|
|
219
|
+
A simple tool for reviewing markdown with inline comments.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## How It Works
|
|
224
|
+
|
|
225
|
+
readit follows a simple loop: **read → comment → extract**.
|
|
226
|
+
|
|
227
|
+
### 1. Read
|
|
228
|
+
|
|
229
|
+
You're already doing this. Open any markdown file with \`readit <file.md>\` and it renders in your browser with a clean reading experience.
|
|
230
|
+
|
|
231
|
+
### 2. Comment
|
|
232
|
+
|
|
233
|
+
Select any text to add a comment. Try it now — **select this sentence** and type your first comment.
|
|
234
|
+
|
|
235
|
+
Your comments appear as margin notes next to the highlighted text, just like reviewing a document in Google Docs. Add as many as you need.
|
|
236
|
+
|
|
237
|
+
### 3. Extract
|
|
238
|
+
|
|
239
|
+
When you're done reviewing, click the menu in the top-right and choose **Copy as Prompt**. This exports all your comments in a format ready for Claude, ChatGPT, or any AI assistant.
|
|
240
|
+
|
|
241
|
+
You can also export as JSON if you prefer structured data.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## Everything is Plain Markdown
|
|
246
|
+
|
|
247
|
+
Your comments are saved as \`.comments.md\` files in \`~/.readit/comments/\`. No database, no lock-in — just readable markdown files you can version control, search, or edit by hand.
|
|
248
|
+
|
|
249
|
+
Each comment file looks something like this:
|
|
250
|
+
|
|
251
|
+
\`\`\`markdown
|
|
252
|
+
## Comment 1
|
|
253
|
+
**Selected:** "select this sentence"
|
|
254
|
+
**Comment:** This is my first comment!
|
|
255
|
+
**Created:** 2024-01-15T10:30:00Z
|
|
256
|
+
\`\`\`
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Navigating Comments
|
|
261
|
+
|
|
262
|
+
Once you have multiple comments, use the navigation bar at the bottom of the screen to jump between them. You can also use keyboard shortcuts:
|
|
263
|
+
|
|
264
|
+
| Shortcut | Action |
|
|
265
|
+
|----------|--------|
|
|
266
|
+
| \`Alt + ↑\` | Previous comment |
|
|
267
|
+
| \`Alt + ↓\` | Next comment |
|
|
268
|
+
| \`⌘ + C\` | Copy selected text (raw) |
|
|
269
|
+
| \`⌘ + Shift + C\` | Copy selected text with context (for AI) |
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Quick Start
|
|
274
|
+
|
|
275
|
+
\`\`\`bash
|
|
276
|
+
# Review a markdown file
|
|
277
|
+
readit document.md
|
|
278
|
+
|
|
279
|
+
# Use a custom port
|
|
280
|
+
readit document.md --port 3000
|
|
281
|
+
|
|
282
|
+
# Start fresh (clear existing comments)
|
|
283
|
+
readit document.md --clean
|
|
284
|
+
\`\`\`
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Try It Now
|
|
289
|
+
|
|
290
|
+
Go ahead and add a few comments to this document. When you're done, export them and see the output. That's the entire workflow — simple, transparent, and designed for reviewing AI-generated content.
|
|
291
|
+
`;
|
|
292
|
+
|
|
293
|
+
const WELCOME_PATH = join(os.homedir(), ".readit", "welcome.md");
|
|
294
|
+
|
|
295
|
+
// ─── Program ─────────────────────────────────────────────────────────
|
|
296
|
+
|
|
188
297
|
program
|
|
189
298
|
.name("readit")
|
|
190
299
|
.description("Review Markdown and HTML documents with inline comments")
|
|
@@ -273,9 +382,9 @@ program
|
|
|
273
382
|
}
|
|
274
383
|
});
|
|
275
384
|
|
|
276
|
-
// Main review command (default) — accepts
|
|
385
|
+
// Main review command (default) — accepts zero or more files/directories
|
|
277
386
|
program
|
|
278
|
-
.argument("
|
|
387
|
+
.argument("[files...]", "Markdown or HTML files/directories to review")
|
|
279
388
|
.option("-p, --port <number>", "Port to run server on", "4567")
|
|
280
389
|
.option("--host <address>", "Host address to bind to", "127.0.0.1")
|
|
281
390
|
.option("--no-open", "Don't automatically open browser")
|
|
@@ -290,11 +399,27 @@ program
|
|
|
290
399
|
clean: boolean;
|
|
291
400
|
},
|
|
292
401
|
) => {
|
|
293
|
-
|
|
402
|
+
let files: FileEntry[];
|
|
294
403
|
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
404
|
+
if (fileArgs.length === 0) {
|
|
405
|
+
if (isOnboarded()) {
|
|
406
|
+
files = [];
|
|
407
|
+
} else {
|
|
408
|
+
files = [
|
|
409
|
+
{
|
|
410
|
+
content: WELCOME_CONTENT,
|
|
411
|
+
type: "markdown" as DocumentType,
|
|
412
|
+
filePath: WELCOME_PATH,
|
|
413
|
+
},
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
files = resolveFiles(fileArgs);
|
|
418
|
+
|
|
419
|
+
if (files.length === 0) {
|
|
420
|
+
console.error("error: no reviewable files found");
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
298
423
|
}
|
|
299
424
|
|
|
300
425
|
const preferredPort = Number.parseInt(options.port, 10);
|
|
@@ -316,9 +441,21 @@ program
|
|
|
316
441
|
clean: options.clean,
|
|
317
442
|
});
|
|
318
443
|
|
|
319
|
-
|
|
444
|
+
if (files.length === 0) {
|
|
445
|
+
console.log(`
|
|
446
|
+
readit - Document Review Tool
|
|
320
447
|
|
|
321
|
-
|
|
448
|
+
URL: ${url}
|
|
449
|
+
|
|
450
|
+
No files specified. Add files with:
|
|
451
|
+
readit open <file.md>
|
|
452
|
+
|
|
453
|
+
Server running. Press Ctrl+C to stop.
|
|
454
|
+
`);
|
|
455
|
+
} else {
|
|
456
|
+
const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
|
|
457
|
+
|
|
458
|
+
console.log(`
|
|
322
459
|
readit - Document Review Tool
|
|
323
460
|
|
|
324
461
|
${files.length === 1 ? "File:" : "Files:"}
|
|
@@ -328,11 +465,17 @@ ${fileList.join("\n")}
|
|
|
328
465
|
Server running. Close browser tab to stop.
|
|
329
466
|
Press Ctrl+C to force stop.
|
|
330
467
|
`);
|
|
468
|
+
}
|
|
331
469
|
|
|
332
470
|
if (options.open) {
|
|
333
471
|
open(url);
|
|
334
472
|
}
|
|
335
473
|
|
|
474
|
+
// Mark onboarding complete on first server start
|
|
475
|
+
if (fileArgs.length === 0) {
|
|
476
|
+
await markOnboarded();
|
|
477
|
+
}
|
|
478
|
+
|
|
336
479
|
// Graceful shutdown on Ctrl+C
|
|
337
480
|
process.on("SIGINT", async () => {
|
|
338
481
|
console.log("\n\nShutting down...");
|
|
@@ -362,13 +505,15 @@ program
|
|
|
362
505
|
// Resolve and validate files
|
|
363
506
|
const resolvedFiles: { path: string; type: DocumentType }[] = [];
|
|
364
507
|
for (const arg of fileArgs) {
|
|
365
|
-
const
|
|
508
|
+
const inputPath = resolve(process.cwd(), arg);
|
|
366
509
|
|
|
367
|
-
if (!existsSync(
|
|
368
|
-
console.error(`error: not found: ${
|
|
510
|
+
if (!existsSync(inputPath)) {
|
|
511
|
+
console.error(`error: not found: ${inputPath}`);
|
|
369
512
|
process.exit(1);
|
|
370
513
|
}
|
|
371
514
|
|
|
515
|
+
const filePath = realpathSync(inputPath);
|
|
516
|
+
|
|
372
517
|
const type = getFileType(filePath);
|
|
373
518
|
if (!type) {
|
|
374
519
|
console.error(
|
|
@@ -388,7 +533,7 @@ program
|
|
|
388
533
|
for (const file of resolvedFiles) {
|
|
389
534
|
try {
|
|
390
535
|
const res = await fetch(
|
|
391
|
-
`http://127.0.0.1:${server.port}/api/
|
|
536
|
+
`http://127.0.0.1:${server.port}/api/documents`,
|
|
392
537
|
{
|
|
393
538
|
method: "POST",
|
|
394
539
|
headers: { "Content-Type": "application/json" },
|
|
@@ -403,7 +548,11 @@ program
|
|
|
403
548
|
}
|
|
404
549
|
|
|
405
550
|
const data = await res.json();
|
|
406
|
-
|
|
551
|
+
if (data.status === "added") {
|
|
552
|
+
console.log(`Added: ${data.fileName} (${data.type})`);
|
|
553
|
+
} else {
|
|
554
|
+
console.log(`Present: ${data.fileName} (${data.type})`);
|
|
555
|
+
}
|
|
407
556
|
} catch (err) {
|
|
408
557
|
console.error(
|
|
409
558
|
"error: failed to connect to server:",
|
|
@@ -421,7 +570,6 @@ program
|
|
|
421
570
|
console.log("No running server found, starting new one...\n");
|
|
422
571
|
|
|
423
572
|
const files = resolvedFiles.map((f) => ({
|
|
424
|
-
content: readFileSync(f.path, "utf-8"),
|
|
425
573
|
type: f.type,
|
|
426
574
|
filePath: f.path,
|
|
427
575
|
}));
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { useState } from "react";
|
|
13
13
|
import { useCommentContext } from "../contexts/CommentContext";
|
|
14
14
|
import { useLayoutContext } from "../contexts/LayoutContext";
|
|
15
|
+
import { useLocale } from "../contexts/LocaleContext";
|
|
15
16
|
import { RawModal } from "./RawModal";
|
|
16
17
|
import { SettingsModal } from "./SettingsModal";
|
|
17
18
|
import { Button } from "./ui/Button";
|
|
@@ -38,6 +39,7 @@ export function ActionsMenu({
|
|
|
38
39
|
}: ActionsMenuProps) {
|
|
39
40
|
const { commentCount } = useCommentContext();
|
|
40
41
|
const { isFullscreen, toggleLayoutMode } = useLayoutContext();
|
|
42
|
+
const { t } = useLocale();
|
|
41
43
|
|
|
42
44
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
43
45
|
const [rawModalOpen, setRawModalOpen] = useState(false);
|
|
@@ -51,7 +53,7 @@ export function ActionsMenu({
|
|
|
51
53
|
variant="ghost"
|
|
52
54
|
size="icon"
|
|
53
55
|
className="size-7"
|
|
54
|
-
aria-label="
|
|
56
|
+
aria-label={t("actions.ariaLabel")}
|
|
55
57
|
>
|
|
56
58
|
<MoreHorizontal className="w-4 h-4" />
|
|
57
59
|
</Button>
|
|
@@ -59,40 +61,40 @@ export function ActionsMenu({
|
|
|
59
61
|
<DropdownMenuContent align="end" className="min-w-[160px]">
|
|
60
62
|
<DropdownMenuItem onSelect={() => toggleLayoutMode()}>
|
|
61
63
|
{isFullscreen ? <Minimize2 /> : <Maximize2 />}
|
|
62
|
-
{isFullscreen ? "
|
|
64
|
+
{isFullscreen ? t("actions.centered") : t("actions.fullscreen")}
|
|
63
65
|
</DropdownMenuItem>
|
|
64
66
|
<DropdownMenuItem onSelect={() => setSettingsOpen(true)}>
|
|
65
67
|
<Settings />
|
|
66
|
-
|
|
68
|
+
{t("actions.settings")}
|
|
67
69
|
</DropdownMenuItem>
|
|
68
70
|
<DropdownMenuSeparator />
|
|
69
71
|
<DropdownMenuItem onSelect={() => onReload()}>
|
|
70
72
|
<RefreshCw />
|
|
71
|
-
|
|
73
|
+
{t("actions.reload")}
|
|
72
74
|
</DropdownMenuItem>
|
|
73
75
|
{commentCount > 0 && (
|
|
74
76
|
<>
|
|
75
77
|
<DropdownMenuItem
|
|
76
78
|
onSelect={() => onCopyAll()}
|
|
77
|
-
title="
|
|
79
|
+
title={t("actions.copyAllAITitle")}
|
|
78
80
|
>
|
|
79
81
|
<BotMessageSquare />
|
|
80
|
-
|
|
82
|
+
{t("actions.copyAllAI")}
|
|
81
83
|
</DropdownMenuItem>
|
|
82
84
|
<DropdownMenuItem
|
|
83
85
|
onSelect={() => onCopyAllRaw()}
|
|
84
|
-
title="
|
|
86
|
+
title={t("actions.copyAllRawTitle")}
|
|
85
87
|
>
|
|
86
88
|
<TextQuote />
|
|
87
|
-
|
|
89
|
+
{t("actions.copyAllRaw")}
|
|
88
90
|
</DropdownMenuItem>
|
|
89
91
|
<DropdownMenuItem onSelect={() => onExportJson()}>
|
|
90
92
|
<FileDown />
|
|
91
|
-
|
|
93
|
+
{t("actions.exportJson")}
|
|
92
94
|
</DropdownMenuItem>
|
|
93
95
|
<DropdownMenuItem onSelect={() => setRawModalOpen(true)}>
|
|
94
96
|
<FileText />
|
|
95
|
-
|
|
97
|
+
{t("actions.viewRaw")}
|
|
96
98
|
</DropdownMenuItem>
|
|
97
99
|
</>
|
|
98
100
|
)}
|
|
@@ -1,75 +1,136 @@
|
|
|
1
|
-
import {
|
|
2
|
-
// Import only the languages we need (reduces bundle by ~800KB)
|
|
3
|
-
import bash from "react-syntax-highlighter/dist/esm/languages/prism/bash";
|
|
4
|
-
import css from "react-syntax-highlighter/dist/esm/languages/prism/css";
|
|
5
|
-
import diff from "react-syntax-highlighter/dist/esm/languages/prism/diff";
|
|
6
|
-
import go from "react-syntax-highlighter/dist/esm/languages/prism/go";
|
|
7
|
-
import graphql from "react-syntax-highlighter/dist/esm/languages/prism/graphql";
|
|
8
|
-
import javascript from "react-syntax-highlighter/dist/esm/languages/prism/javascript";
|
|
9
|
-
import json from "react-syntax-highlighter/dist/esm/languages/prism/json";
|
|
10
|
-
import jsx from "react-syntax-highlighter/dist/esm/languages/prism/jsx";
|
|
11
|
-
import markdown from "react-syntax-highlighter/dist/esm/languages/prism/markdown";
|
|
12
|
-
import python from "react-syntax-highlighter/dist/esm/languages/prism/python";
|
|
13
|
-
import rust from "react-syntax-highlighter/dist/esm/languages/prism/rust";
|
|
14
|
-
import sql from "react-syntax-highlighter/dist/esm/languages/prism/sql";
|
|
15
|
-
import tsx from "react-syntax-highlighter/dist/esm/languages/prism/tsx";
|
|
16
|
-
import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript";
|
|
17
|
-
import yaml from "react-syntax-highlighter/dist/esm/languages/prism/yaml";
|
|
18
|
-
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
19
2
|
import { MermaidDiagram } from "./MermaidDiagram";
|
|
20
3
|
|
|
21
|
-
// Register languages
|
|
22
|
-
SyntaxHighlighter.registerLanguage("bash", bash);
|
|
23
|
-
SyntaxHighlighter.registerLanguage("sh", bash);
|
|
24
|
-
SyntaxHighlighter.registerLanguage("shell", bash);
|
|
25
|
-
SyntaxHighlighter.registerLanguage("css", css);
|
|
26
|
-
SyntaxHighlighter.registerLanguage("diff", diff);
|
|
27
|
-
SyntaxHighlighter.registerLanguage("go", go);
|
|
28
|
-
SyntaxHighlighter.registerLanguage("graphql", graphql);
|
|
29
|
-
SyntaxHighlighter.registerLanguage("javascript", javascript);
|
|
30
|
-
SyntaxHighlighter.registerLanguage("js", javascript);
|
|
31
|
-
SyntaxHighlighter.registerLanguage("json", json);
|
|
32
|
-
SyntaxHighlighter.registerLanguage("jsx", jsx);
|
|
33
|
-
SyntaxHighlighter.registerLanguage("markdown", markdown);
|
|
34
|
-
SyntaxHighlighter.registerLanguage("md", markdown);
|
|
35
|
-
SyntaxHighlighter.registerLanguage("python", python);
|
|
36
|
-
SyntaxHighlighter.registerLanguage("py", python);
|
|
37
|
-
SyntaxHighlighter.registerLanguage("rust", rust);
|
|
38
|
-
SyntaxHighlighter.registerLanguage("rs", rust);
|
|
39
|
-
SyntaxHighlighter.registerLanguage("sql", sql);
|
|
40
|
-
SyntaxHighlighter.registerLanguage("tsx", tsx);
|
|
41
|
-
SyntaxHighlighter.registerLanguage("typescript", typescript);
|
|
42
|
-
SyntaxHighlighter.registerLanguage("ts", typescript);
|
|
43
|
-
SyntaxHighlighter.registerLanguage("yaml", yaml);
|
|
44
|
-
SyntaxHighlighter.registerLanguage("yml", yaml);
|
|
45
|
-
|
|
46
4
|
const CODE_BLOCK_STYLE = {
|
|
47
5
|
margin: "1.5em 0",
|
|
48
6
|
borderRadius: "0.5em",
|
|
49
7
|
fontSize: "0.875em",
|
|
50
8
|
};
|
|
51
9
|
|
|
10
|
+
interface SyntaxHighlighterModule {
|
|
11
|
+
SyntaxHighlighter: typeof import("react-syntax-highlighter").PrismLight;
|
|
12
|
+
oneDark: typeof import("react-syntax-highlighter/dist/esm/styles/prism").oneDark;
|
|
13
|
+
}
|
|
14
|
+
|
|
52
15
|
interface CodeBlockProps {
|
|
53
16
|
className?: string;
|
|
54
17
|
children?: React.ReactNode;
|
|
55
18
|
}
|
|
56
19
|
|
|
57
|
-
|
|
58
|
-
// Extract language from className (e.g., "language-typescript" -> "typescript")
|
|
59
|
-
const langMatch = className?.match(/language-(\w+)/);
|
|
60
|
-
const language = langMatch?.[1] ?? "";
|
|
61
|
-
const codeString = String(children).replace(/\n$/, "");
|
|
20
|
+
let syntaxHighlighterPromise: Promise<SyntaxHighlighterModule> | null = null;
|
|
62
21
|
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
return
|
|
22
|
+
async function loadSyntaxHighlighter(): Promise<SyntaxHighlighterModule> {
|
|
23
|
+
if (syntaxHighlighterPromise) {
|
|
24
|
+
return syntaxHighlighterPromise;
|
|
66
25
|
}
|
|
67
26
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
27
|
+
syntaxHighlighterPromise = Promise.all([
|
|
28
|
+
import("react-syntax-highlighter"),
|
|
29
|
+
import("react-syntax-highlighter/dist/esm/styles/prism"),
|
|
30
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/bash"),
|
|
31
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/css"),
|
|
32
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/diff"),
|
|
33
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/go"),
|
|
34
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/graphql"),
|
|
35
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/javascript"),
|
|
36
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/json"),
|
|
37
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/jsx"),
|
|
38
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/markdown"),
|
|
39
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/python"),
|
|
40
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/rust"),
|
|
41
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/sql"),
|
|
42
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/tsx"),
|
|
43
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/typescript"),
|
|
44
|
+
import("react-syntax-highlighter/dist/esm/languages/prism/yaml"),
|
|
45
|
+
]).then(
|
|
46
|
+
([
|
|
47
|
+
syntaxModule,
|
|
48
|
+
styleModule,
|
|
49
|
+
bash,
|
|
50
|
+
css,
|
|
51
|
+
diff,
|
|
52
|
+
go,
|
|
53
|
+
graphql,
|
|
54
|
+
javascript,
|
|
55
|
+
json,
|
|
56
|
+
jsx,
|
|
57
|
+
markdown,
|
|
58
|
+
python,
|
|
59
|
+
rust,
|
|
60
|
+
sql,
|
|
61
|
+
tsx,
|
|
62
|
+
typescript,
|
|
63
|
+
yaml,
|
|
64
|
+
]) => {
|
|
65
|
+
const SyntaxHighlighter = syntaxModule.PrismLight;
|
|
66
|
+
|
|
67
|
+
SyntaxHighlighter.registerLanguage("bash", bash.default);
|
|
68
|
+
SyntaxHighlighter.registerLanguage("sh", bash.default);
|
|
69
|
+
SyntaxHighlighter.registerLanguage("shell", bash.default);
|
|
70
|
+
SyntaxHighlighter.registerLanguage("css", css.default);
|
|
71
|
+
SyntaxHighlighter.registerLanguage("diff", diff.default);
|
|
72
|
+
SyntaxHighlighter.registerLanguage("go", go.default);
|
|
73
|
+
SyntaxHighlighter.registerLanguage("graphql", graphql.default);
|
|
74
|
+
SyntaxHighlighter.registerLanguage("javascript", javascript.default);
|
|
75
|
+
SyntaxHighlighter.registerLanguage("js", javascript.default);
|
|
76
|
+
SyntaxHighlighter.registerLanguage("json", json.default);
|
|
77
|
+
SyntaxHighlighter.registerLanguage("jsx", jsx.default);
|
|
78
|
+
SyntaxHighlighter.registerLanguage("markdown", markdown.default);
|
|
79
|
+
SyntaxHighlighter.registerLanguage("md", markdown.default);
|
|
80
|
+
SyntaxHighlighter.registerLanguage("python", python.default);
|
|
81
|
+
SyntaxHighlighter.registerLanguage("py", python.default);
|
|
82
|
+
SyntaxHighlighter.registerLanguage("rust", rust.default);
|
|
83
|
+
SyntaxHighlighter.registerLanguage("rs", rust.default);
|
|
84
|
+
SyntaxHighlighter.registerLanguage("sql", sql.default);
|
|
85
|
+
SyntaxHighlighter.registerLanguage("tsx", tsx.default);
|
|
86
|
+
SyntaxHighlighter.registerLanguage("typescript", typescript.default);
|
|
87
|
+
SyntaxHighlighter.registerLanguage("ts", typescript.default);
|
|
88
|
+
SyntaxHighlighter.registerLanguage("yaml", yaml.default);
|
|
89
|
+
SyntaxHighlighter.registerLanguage("yml", yaml.default);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
SyntaxHighlighter,
|
|
93
|
+
oneDark: styleModule.oneDark,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return syntaxHighlighterPromise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function LazySyntaxCodeBlock({
|
|
102
|
+
codeString,
|
|
103
|
+
language,
|
|
104
|
+
}: {
|
|
105
|
+
codeString: string;
|
|
106
|
+
language: string;
|
|
107
|
+
}) {
|
|
108
|
+
const [module, setModule] = useState<SyntaxHighlighterModule | null>(null);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
let cancelled = false;
|
|
112
|
+
|
|
113
|
+
loadSyntaxHighlighter().then((loaded) => {
|
|
114
|
+
if (!cancelled) {
|
|
115
|
+
setModule(loaded);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
cancelled = true;
|
|
121
|
+
};
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
if (!module) {
|
|
125
|
+
return (
|
|
126
|
+
<pre style={CODE_BLOCK_STYLE}>
|
|
127
|
+
<code>{codeString}</code>
|
|
128
|
+
</pre>
|
|
129
|
+
);
|
|
71
130
|
}
|
|
72
131
|
|
|
132
|
+
const { SyntaxHighlighter, oneDark } = module;
|
|
133
|
+
|
|
73
134
|
return (
|
|
74
135
|
<SyntaxHighlighter
|
|
75
136
|
style={oneDark}
|
|
@@ -81,3 +142,19 @@ export function CodeBlock({ className, children }: CodeBlockProps) {
|
|
|
81
142
|
</SyntaxHighlighter>
|
|
82
143
|
);
|
|
83
144
|
}
|
|
145
|
+
|
|
146
|
+
export function CodeBlock({ className, children }: CodeBlockProps) {
|
|
147
|
+
const langMatch = className?.match(/language-(\w+)/);
|
|
148
|
+
const language = langMatch?.[1] ?? "";
|
|
149
|
+
const codeString = String(children).replace(/\n$/, "");
|
|
150
|
+
|
|
151
|
+
if (language === "mermaid") {
|
|
152
|
+
return <MermaidDiagram code={codeString} />;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!langMatch && !String(children).includes("\n")) {
|
|
156
|
+
return <code className={className}>{children}</code>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return <LazySyntaxCodeBlock codeString={codeString} language={language} />;
|
|
160
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type Highlighter,
|
|
17
17
|
} from "../../lib/highlight";
|
|
18
18
|
import { cn, getTextContent } from "../../lib/utils";
|
|
19
|
+
import { useAppStore } from "../../store";
|
|
19
20
|
import {
|
|
20
21
|
AnchorConfidences,
|
|
21
22
|
type Comment,
|
|
@@ -23,8 +24,8 @@ import {
|
|
|
23
24
|
FontFamilies,
|
|
24
25
|
type SelectionRange,
|
|
25
26
|
} from "../../types";
|
|
26
|
-
import { CodeBlock } from "./CodeBlock";
|
|
27
27
|
import { IframeContainer } from "./IframeContainer";
|
|
28
|
+
import { createCodeComponent } from "./InlineCode";
|
|
28
29
|
|
|
29
30
|
function createHeadingComponent(
|
|
30
31
|
level: 1 | 2 | 3 | 4 | 5 | 6,
|
|
@@ -101,7 +102,8 @@ export function DocumentViewer({
|
|
|
101
102
|
onHighlightHover,
|
|
102
103
|
onHighlightClick,
|
|
103
104
|
}: DocumentViewerProps) {
|
|
104
|
-
const { isFullscreen, fontFamily } = useLayoutContext();
|
|
105
|
+
const { isFullscreen, fontFamily, editorScheme } = useLayoutContext();
|
|
106
|
+
const workingDirectory = useAppStore((s) => s.workingDirectory);
|
|
105
107
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
106
108
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
107
109
|
const adapterRef = useRef<Highlighter | null>(null);
|
|
@@ -208,16 +210,15 @@ export function DocumentViewer({
|
|
|
208
210
|
h4: createHeadingComponent(4, headings, headingIndexRef),
|
|
209
211
|
h5: createHeadingComponent(5, headings, headingIndexRef),
|
|
210
212
|
h6: createHeadingComponent(6, headings, headingIndexRef),
|
|
211
|
-
code:
|
|
213
|
+
code: createCodeComponent(editorScheme, workingDirectory),
|
|
212
214
|
}),
|
|
213
|
-
[headings],
|
|
215
|
+
[headings, editorScheme, workingDirectory],
|
|
214
216
|
);
|
|
215
217
|
|
|
216
218
|
if (type === "html") {
|
|
217
219
|
return (
|
|
218
220
|
<main className="flex-1 min-w-0 flex flex-col">
|
|
219
221
|
<IframeContainer
|
|
220
|
-
key={content}
|
|
221
222
|
html={content}
|
|
222
223
|
comments={comments}
|
|
223
224
|
pendingSelection={pendingSelection}
|
|
@@ -244,7 +245,6 @@ export function DocumentViewer({
|
|
|
244
245
|
)}
|
|
245
246
|
>
|
|
246
247
|
<Markdown
|
|
247
|
-
key={content}
|
|
248
248
|
components={markdownComponents}
|
|
249
249
|
remarkPlugins={[remarkGfm]}
|
|
250
250
|
rehypePlugins={[rehypeRaw]}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
buildEditorUri,
|
|
4
|
+
parseFilePath,
|
|
5
|
+
resolveAbsolutePath,
|
|
6
|
+
} from "../../lib/editor-links";
|
|
7
|
+
import type { EditorScheme } from "../../types";
|
|
8
|
+
import { EditorSchemes } from "../../types";
|
|
9
|
+
import { CodeBlock } from "./CodeBlock";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a combined code component for react-markdown that:
|
|
13
|
+
* - Routes fenced code blocks to CodeBlock (syntax highlighting)
|
|
14
|
+
* - Wraps inline code containing file paths with editor links
|
|
15
|
+
* - Falls back to plain <code> for non-file-path inline code
|
|
16
|
+
*/
|
|
17
|
+
export function createCodeComponent(
|
|
18
|
+
editorScheme: EditorScheme,
|
|
19
|
+
workingDirectory: string | null,
|
|
20
|
+
) {
|
|
21
|
+
return function CodeComponent({
|
|
22
|
+
children,
|
|
23
|
+
className,
|
|
24
|
+
...props
|
|
25
|
+
}: ComponentPropsWithoutRef<"code">) {
|
|
26
|
+
// Fenced code blocks have className (e.g., "language-ts") or contain newlines
|
|
27
|
+
if (className || String(children).includes("\n")) {
|
|
28
|
+
return <CodeBlock className={className}>{children}</CodeBlock>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Inline code — check for file path patterns
|
|
32
|
+
if (editorScheme === EditorSchemes.NONE || !workingDirectory) {
|
|
33
|
+
return <code {...props}>{children}</code>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const text = typeof children === "string" ? children : "";
|
|
37
|
+
if (!text) {
|
|
38
|
+
return <code {...props}>{children}</code>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const match = parseFilePath(text);
|
|
42
|
+
if (!match) {
|
|
43
|
+
return <code {...props}>{children}</code>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const absolutePath = resolveAbsolutePath(match.path, workingDirectory);
|
|
47
|
+
const uri = buildEditorUri(
|
|
48
|
+
editorScheme,
|
|
49
|
+
absolutePath,
|
|
50
|
+
match.line,
|
|
51
|
+
match.col,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<a href={uri} title={`Open in ${editorScheme}`} className="editor-link">
|
|
56
|
+
<code {...props}>{children}</code>
|
|
57
|
+
</a>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
}
|