@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.
Files changed (49) hide show
  1. package/biome.json +1 -1
  2. package/bun.lock +86 -72
  3. package/package.json +12 -11
  4. package/src/App.tsx +23 -6
  5. package/src/cli/index.ts +167 -19
  6. package/src/components/ActionsMenu.tsx +12 -10
  7. package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
  8. package/src/components/DocumentViewer/DocumentViewer.tsx +6 -6
  9. package/src/components/DocumentViewer/InlineCode.tsx +60 -0
  10. package/src/components/FloatingTOC.tsx +4 -2
  11. package/src/components/Header.tsx +3 -1
  12. package/src/components/InlineEditor.tsx +4 -2
  13. package/src/components/MarginNote.tsx +17 -8
  14. package/src/components/RawModal.tsx +9 -7
  15. package/src/components/ReanchorConfirm.tsx +6 -3
  16. package/src/components/SettingsModal.tsx +112 -23
  17. package/src/components/ShortcutCapture.tsx +4 -1
  18. package/src/components/ShortcutList.tsx +50 -9
  19. package/src/components/comments/CommentBadge.tsx +7 -1
  20. package/src/components/comments/CommentInput.tsx +13 -18
  21. package/src/components/comments/CommentListItem.tsx +15 -5
  22. package/src/components/comments/CommentManager.tsx +14 -7
  23. package/src/components/comments/CommentNav.tsx +8 -3
  24. package/src/contexts/CommentContext.tsx +16 -9
  25. package/src/contexts/LayoutContext.tsx +17 -5
  26. package/src/contexts/LocaleContext.tsx +35 -0
  27. package/src/hooks/useClipboard.ts +11 -8
  28. package/src/hooks/useDocument.ts +33 -18
  29. package/src/hooks/useEditorScheme.ts +51 -0
  30. package/src/hooks/useFontPreference.ts +5 -22
  31. package/src/hooks/useKeybindings.ts +6 -18
  32. package/src/hooks/useLocalePreference.ts +42 -0
  33. package/src/index.css +87 -26
  34. package/src/lib/editor-links.ts +59 -0
  35. package/src/lib/highlight/dom.ts +126 -54
  36. package/src/lib/highlight/highlighter.ts +10 -10
  37. package/src/lib/i18n/completeness.test.ts +51 -0
  38. package/src/lib/i18n/en.ts +139 -0
  39. package/src/lib/i18n/index.ts +3 -0
  40. package/src/lib/i18n/ja.ts +141 -0
  41. package/src/lib/i18n/translations.test.ts +39 -0
  42. package/src/lib/i18n/translations.ts +27 -0
  43. package/src/lib/i18n/types.ts +145 -0
  44. package/src/lib/shortcut-registry.ts +1 -1
  45. package/src/main.tsx +4 -1
  46. package/src/server/index.ts +160 -117
  47. package/src/store/index.test.ts +22 -0
  48. package/src/store/index.ts +24 -4
  49. 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 filePath = resolve(process.cwd(), arg);
148
+ const inputPath = resolve(process.cwd(), arg);
149
149
 
150
- if (!existsSync(filePath)) {
151
- console.error(`error: not found: ${filePath}`);
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 one or more files/directories
385
+ // Main review command (default) — accepts zero or more files/directories
277
386
  program
278
- .argument("<files...>", "Markdown or HTML files/directories to review")
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
- const files = resolveFiles(fileArgs);
402
+ let files: FileEntry[];
294
403
 
295
- if (files.length === 0) {
296
- console.error("error: no reviewable files found");
297
- process.exit(1);
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
- const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
444
+ if (files.length === 0) {
445
+ console.log(`
446
+ readit - Document Review Tool
320
447
 
321
- console.log(`
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 filePath = resolve(process.cwd(), arg);
508
+ const inputPath = resolve(process.cwd(), arg);
366
509
 
367
- if (!existsSync(filePath)) {
368
- console.error(`error: not found: ${filePath}`);
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/files`,
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
- console.log(`Added: ${data.fileName} (${data.type})`);
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="Actions menu"
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 ? "Centered" : "Fullscreen"}
64
+ {isFullscreen ? t("actions.centered") : t("actions.fullscreen")}
63
65
  </DropdownMenuItem>
64
66
  <DropdownMenuItem onSelect={() => setSettingsOpen(true)}>
65
67
  <Settings />
66
- Settings
68
+ {t("actions.settings")}
67
69
  </DropdownMenuItem>
68
70
  <DropdownMenuSeparator />
69
71
  <DropdownMenuItem onSelect={() => onReload()}>
70
72
  <RefreshCw />
71
- Reload
73
+ {t("actions.reload")}
72
74
  </DropdownMenuItem>
73
75
  {commentCount > 0 && (
74
76
  <>
75
77
  <DropdownMenuItem
76
78
  onSelect={() => onCopyAll()}
77
- title="Copy in prompt format for AI assistants"
79
+ title={t("actions.copyAllAITitle")}
78
80
  >
79
81
  <BotMessageSquare />
80
- Copy All (AI)
82
+ {t("actions.copyAllAI")}
81
83
  </DropdownMenuItem>
82
84
  <DropdownMenuItem
83
85
  onSelect={() => onCopyAllRaw()}
84
- title="Copy as plain text"
86
+ title={t("actions.copyAllRawTitle")}
85
87
  >
86
88
  <TextQuote />
87
- Copy All (Raw)
89
+ {t("actions.copyAllRaw")}
88
90
  </DropdownMenuItem>
89
91
  <DropdownMenuItem onSelect={() => onExportJson()}>
90
92
  <FileDown />
91
- Export JSON
93
+ {t("actions.exportJson")}
92
94
  </DropdownMenuItem>
93
95
  <DropdownMenuItem onSelect={() => setRawModalOpen(true)}>
94
96
  <FileText />
95
- View Raw
97
+ {t("actions.viewRaw")}
96
98
  </DropdownMenuItem>
97
99
  </>
98
100
  )}
@@ -1,75 +1,136 @@
1
- import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
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
- export function CodeBlock({ className, children }: CodeBlockProps) {
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
- // Mermaid diagrams
64
- if (language === "mermaid") {
65
- return <MermaidDiagram code={codeString} />;
22
+ async function loadSyntaxHighlighter(): Promise<SyntaxHighlighterModule> {
23
+ if (syntaxHighlighterPromise) {
24
+ return syntaxHighlighterPromise;
66
25
  }
67
26
 
68
- // Inline code (no language specified and no newlines)
69
- if (!langMatch && !String(children).includes("\n")) {
70
- return <code className={className}>{children}</code>;
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: CodeBlock,
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
+ }