@peaske7/readit 0.1.6 → 0.1.8
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 +36 -16
- package/src/cli/index.ts +338 -70
- package/src/components/ActionsMenu.tsx +12 -10
- package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
- package/src/components/DocumentViewer/DocumentViewer.tsx +10 -8
- 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 +197 -124
- package/src/store/index.test.ts +22 -0
- package/src/store/index.ts +24 -4
- package/src/types/index.ts +12 -0
package/src/App.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { TableOfContents } from "./components/TableOfContents";
|
|
|
13
13
|
import { textVariants } from "./components/ui/Text";
|
|
14
14
|
import { CommentContext, CommentProvider } from "./contexts/CommentContext";
|
|
15
15
|
import { LayoutContext, LayoutProvider } from "./contexts/LayoutContext";
|
|
16
|
+
import { useLocale } from "./contexts/LocaleContext";
|
|
16
17
|
import { useClipboard } from "./hooks/useClipboard";
|
|
17
18
|
import { useDocument } from "./hooks/useDocument";
|
|
18
19
|
import { useHeadings } from "./hooks/useHeadings";
|
|
@@ -37,7 +38,13 @@ const TOASTER_OPTIONS = {
|
|
|
37
38
|
},
|
|
38
39
|
};
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
interface AppContentProps {
|
|
42
|
+
document: NonNullable<ReturnType<typeof useDocument>["document"]>;
|
|
43
|
+
reload: ReturnType<typeof useDocument>["reload"];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function AppContent({ document, reload }: AppContentProps) {
|
|
47
|
+
const { t } = useLocale();
|
|
41
48
|
const {
|
|
42
49
|
comments,
|
|
43
50
|
sortedComments,
|
|
@@ -51,8 +58,6 @@ function AppContent() {
|
|
|
51
58
|
navigateNext,
|
|
52
59
|
} = use(CommentContext)!;
|
|
53
60
|
|
|
54
|
-
const { document, reload } = useDocument();
|
|
55
|
-
|
|
56
61
|
const {
|
|
57
62
|
selection,
|
|
58
63
|
highlightPositions,
|
|
@@ -74,6 +79,7 @@ function AppContent() {
|
|
|
74
79
|
document: document ?? undefined,
|
|
75
80
|
selection: selection ?? undefined,
|
|
76
81
|
clearSelection,
|
|
82
|
+
t,
|
|
77
83
|
});
|
|
78
84
|
|
|
79
85
|
const { shortcuts, isFullscreen } = use(LayoutContext)!;
|
|
@@ -137,11 +143,6 @@ function AppContent() {
|
|
|
137
143
|
}
|
|
138
144
|
}, []);
|
|
139
145
|
|
|
140
|
-
useEffect(() => {
|
|
141
|
-
const eventSource = new EventSource("/api/heartbeat");
|
|
142
|
-
return () => eventSource.close();
|
|
143
|
-
}, []);
|
|
144
|
-
|
|
145
146
|
// Scroll save/restore for tab switching
|
|
146
147
|
const setScrollY = useAppStore((s) => s.setScrollY);
|
|
147
148
|
const savedScrollY = useAppStore(
|
|
@@ -275,6 +276,7 @@ function AppContent() {
|
|
|
275
276
|
/>
|
|
276
277
|
) : (
|
|
277
278
|
<CommentInput
|
|
279
|
+
key={selection.text}
|
|
278
280
|
selectedText={selection.text}
|
|
279
281
|
onSubmit={handleAddComment}
|
|
280
282
|
onCancel={clearSelection}
|
|
@@ -302,7 +304,7 @@ function AppContent() {
|
|
|
302
304
|
<CommentNav />
|
|
303
305
|
|
|
304
306
|
<footer className="py-4 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
|
305
|
-
|
|
307
|
+
{t("app.footer")}
|
|
306
308
|
</footer>
|
|
307
309
|
</div>
|
|
308
310
|
);
|
|
@@ -333,11 +335,17 @@ function useTabKeyboardShortcuts() {
|
|
|
333
335
|
}
|
|
334
336
|
|
|
335
337
|
function App() {
|
|
336
|
-
const {
|
|
338
|
+
const { t } = useLocale();
|
|
339
|
+
const { document, error, isInitialized, reload } = useDocument();
|
|
337
340
|
const documentOrder = useAppStore((s) => s.documentOrder);
|
|
338
341
|
|
|
339
342
|
useTabKeyboardShortcuts();
|
|
340
343
|
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
const eventSource = new EventSource("/api/heartbeat");
|
|
346
|
+
return () => eventSource.close();
|
|
347
|
+
}, []);
|
|
348
|
+
|
|
341
349
|
if (error) {
|
|
342
350
|
return (
|
|
343
351
|
<div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
|
|
@@ -349,7 +357,9 @@ function App() {
|
|
|
349
357
|
if (!isInitialized) {
|
|
350
358
|
return (
|
|
351
359
|
<div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
|
|
352
|
-
<div className="text-zinc-500 dark:text-zinc-400">
|
|
360
|
+
<div className="text-zinc-500 dark:text-zinc-400">
|
|
361
|
+
{t("app.loading")}
|
|
362
|
+
</div>
|
|
353
363
|
</div>
|
|
354
364
|
);
|
|
355
365
|
}
|
|
@@ -358,9 +368,17 @@ function App() {
|
|
|
358
368
|
return (
|
|
359
369
|
<div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex flex-col">
|
|
360
370
|
<TabBar />
|
|
361
|
-
<div className="flex-1 flex items-center justify-center">
|
|
371
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
|
362
372
|
<p className="text-zinc-400 dark:text-zinc-500 text-sm">
|
|
363
|
-
|
|
373
|
+
{t("app.noDocuments")}
|
|
374
|
+
</p>
|
|
375
|
+
<p className="text-zinc-400 dark:text-zinc-500 text-xs">
|
|
376
|
+
{t("app.noDocumentsHintPrefix")}
|
|
377
|
+
{t("app.noDocumentsHintPrefix") && " "}
|
|
378
|
+
<code className="bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded text-xs">
|
|
379
|
+
readit open <file.md>
|
|
380
|
+
</code>{" "}
|
|
381
|
+
{t("app.noDocumentsHintSuffix")}
|
|
364
382
|
</p>
|
|
365
383
|
</div>
|
|
366
384
|
</div>
|
|
@@ -370,7 +388,9 @@ function App() {
|
|
|
370
388
|
if (!document) {
|
|
371
389
|
return (
|
|
372
390
|
<div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
|
|
373
|
-
<div className="text-zinc-500 dark:text-zinc-400">
|
|
391
|
+
<div className="text-zinc-500 dark:text-zinc-400">
|
|
392
|
+
{t("app.loading")}
|
|
393
|
+
</div>
|
|
374
394
|
</div>
|
|
375
395
|
);
|
|
376
396
|
}
|
|
@@ -378,7 +398,7 @@ function App() {
|
|
|
378
398
|
return (
|
|
379
399
|
<>
|
|
380
400
|
<TabBar />
|
|
381
|
-
<LayoutProvider
|
|
401
|
+
<LayoutProvider>
|
|
382
402
|
<CommentProvider
|
|
383
403
|
filePath={document.filePath}
|
|
384
404
|
clean={document.clean}
|
|
@@ -386,7 +406,7 @@ function App() {
|
|
|
386
406
|
fileName={document.fileName}
|
|
387
407
|
documentType={document.type}
|
|
388
408
|
>
|
|
389
|
-
<AppContent />
|
|
409
|
+
<AppContent document={document} reload={reload} />
|
|
390
410
|
</CommentProvider>
|
|
391
411
|
</LayoutProvider>
|
|
392
412
|
</>
|
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";
|
|
@@ -33,17 +34,113 @@ interface ServerInfo {
|
|
|
33
34
|
pid: number;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
interface ServerTarget {
|
|
38
|
+
kind: "existing" | "started";
|
|
39
|
+
port: number;
|
|
40
|
+
url: string;
|
|
41
|
+
server?: { stop(): void };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const READIT_DIR = join(os.homedir(), ".readit");
|
|
45
|
+
const SERVER_INFO_PATH = join(READIT_DIR, "server.json");
|
|
46
|
+
const SERVER_LOCK_PATH = join(READIT_DIR, "server.lock");
|
|
47
|
+
const SERVER_LOCK_MAX_AGE_MS = 30_000;
|
|
48
|
+
const SERVER_LOCK_TIMEOUT_MS = 10_000;
|
|
49
|
+
const SERVER_LOCK_WAIT_MS = 100;
|
|
50
|
+
|
|
51
|
+
function isAlive(pid: number): boolean {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getErrnoCode(err: unknown): string | undefined {
|
|
61
|
+
return err instanceof Error && "code" in err
|
|
62
|
+
? (err as NodeJS.ErrnoException).code
|
|
63
|
+
: undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sleep(ms: number): Promise<void> {
|
|
67
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function clearStaleServerLock(): Promise<void> {
|
|
71
|
+
try {
|
|
72
|
+
const [stats, content] = await Promise.all([
|
|
73
|
+
fs.stat(SERVER_LOCK_PATH),
|
|
74
|
+
fs.readFile(SERVER_LOCK_PATH, "utf-8").catch(() => ""),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const age = Date.now() - stats.mtimeMs;
|
|
78
|
+
let pid: number | undefined;
|
|
79
|
+
|
|
80
|
+
if (content) {
|
|
81
|
+
try {
|
|
82
|
+
const lock = JSON.parse(content) as { pid?: number };
|
|
83
|
+
pid = lock.pid;
|
|
84
|
+
} catch {
|
|
85
|
+
// Ignore malformed lock files and fall back to age-based cleanup.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (age > SERVER_LOCK_MAX_AGE_MS || (pid !== undefined && !isAlive(pid))) {
|
|
90
|
+
await fs.unlink(SERVER_LOCK_PATH).catch(() => {});
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (getErrnoCode(err) !== "ENOENT") throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function withServerLock<T>(run: () => Promise<T>): Promise<T> {
|
|
98
|
+
await fs.mkdir(READIT_DIR, { recursive: true });
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
|
|
101
|
+
while (true) {
|
|
102
|
+
let handle: fs.FileHandle | undefined;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
handle = await fs.open(SERVER_LOCK_PATH, "wx");
|
|
106
|
+
await handle.writeFile(
|
|
107
|
+
JSON.stringify({ pid: process.pid, createdAt: Date.now() }),
|
|
108
|
+
"utf-8",
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
return await run();
|
|
113
|
+
} finally {
|
|
114
|
+
await handle.close().catch(() => {});
|
|
115
|
+
await fs.unlink(SERVER_LOCK_PATH).catch(() => {});
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (handle) {
|
|
119
|
+
await handle.close().catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (getErrnoCode(err) !== "EEXIST") {
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await clearStaleServerLock();
|
|
127
|
+
|
|
128
|
+
if (Date.now() - start >= SERVER_LOCK_TIMEOUT_MS) {
|
|
129
|
+
throw new Error("Timed out waiting for readit server lock");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await sleep(SERVER_LOCK_WAIT_MS);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
38
136
|
|
|
137
|
+
async function discoverServer(): Promise<ServerInfo | null> {
|
|
39
138
|
try {
|
|
40
|
-
const content = readFileSync(
|
|
139
|
+
const content = readFileSync(SERVER_INFO_PATH, "utf-8");
|
|
41
140
|
const info: ServerInfo = JSON.parse(content);
|
|
42
141
|
|
|
43
142
|
// Verify the process is alive
|
|
44
|
-
|
|
45
|
-
process.kill(info.pid, 0);
|
|
46
|
-
} catch {
|
|
143
|
+
if (!isAlive(info.pid)) {
|
|
47
144
|
return null;
|
|
48
145
|
}
|
|
49
146
|
|
|
@@ -61,6 +158,65 @@ async function discoverServer(): Promise<ServerInfo | null> {
|
|
|
61
158
|
}
|
|
62
159
|
}
|
|
63
160
|
|
|
161
|
+
async function attachFiles(
|
|
162
|
+
server: ServerInfo,
|
|
163
|
+
files: { path: string; type: DocumentType }[],
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/api/documents`, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/json" },
|
|
170
|
+
body: JSON.stringify({ path: file.path }),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
const data = await res.json();
|
|
175
|
+
console.error(`error: failed to add ${file.path}: ${data.error}`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const data = await res.json();
|
|
180
|
+
if (data.status === "added") {
|
|
181
|
+
console.log(`Added: ${data.fileName} (${data.type})`);
|
|
182
|
+
} else {
|
|
183
|
+
console.log(`Present: ${data.fileName} (${data.type})`);
|
|
184
|
+
}
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error(
|
|
187
|
+
"error: failed to connect to server:",
|
|
188
|
+
err instanceof Error ? err.message : err,
|
|
189
|
+
);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function getServerTarget(
|
|
196
|
+
files: FileEntry[],
|
|
197
|
+
port: number,
|
|
198
|
+
host: string,
|
|
199
|
+
): Promise<ServerTarget> {
|
|
200
|
+
return withServerLock(async () => {
|
|
201
|
+
const server = await discoverServer();
|
|
202
|
+
if (server) {
|
|
203
|
+
return {
|
|
204
|
+
kind: "existing",
|
|
205
|
+
port: server.port,
|
|
206
|
+
url: `http://127.0.0.1:${server.port}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const started = await startServer({ files, port, host });
|
|
211
|
+
return {
|
|
212
|
+
kind: "started",
|
|
213
|
+
port: started.port,
|
|
214
|
+
url: started.url,
|
|
215
|
+
server: started.server,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
64
220
|
/**
|
|
65
221
|
* Recursively find all .comments.md files in a directory.
|
|
66
222
|
*/
|
|
@@ -116,7 +272,6 @@ function findReviewableFiles(dir: string): FileEntry[] {
|
|
|
116
272
|
const type = getFileType(entry);
|
|
117
273
|
if (type) {
|
|
118
274
|
results.push({
|
|
119
|
-
content: readFileSync(fullPath, "utf-8"),
|
|
120
275
|
type,
|
|
121
276
|
filePath: fullPath,
|
|
122
277
|
});
|
|
@@ -145,13 +300,15 @@ function resolveFiles(args: string[]): FileEntry[] {
|
|
|
145
300
|
const files: FileEntry[] = [];
|
|
146
301
|
|
|
147
302
|
for (const arg of args) {
|
|
148
|
-
const
|
|
303
|
+
const inputPath = resolve(process.cwd(), arg);
|
|
149
304
|
|
|
150
|
-
if (!existsSync(
|
|
151
|
-
console.error(`error: not found: ${
|
|
305
|
+
if (!existsSync(inputPath)) {
|
|
306
|
+
console.error(`error: not found: ${inputPath}`);
|
|
152
307
|
process.exit(1);
|
|
153
308
|
}
|
|
154
309
|
|
|
310
|
+
const filePath = realpathSync(inputPath);
|
|
311
|
+
|
|
155
312
|
const stat = statSync(filePath);
|
|
156
313
|
|
|
157
314
|
if (stat.isDirectory()) {
|
|
@@ -175,7 +332,6 @@ function resolveFiles(args: string[]): FileEntry[] {
|
|
|
175
332
|
|
|
176
333
|
seen.add(filePath);
|
|
177
334
|
files.push({
|
|
178
|
-
content: readFileSync(filePath, "utf-8"),
|
|
179
335
|
type,
|
|
180
336
|
filePath,
|
|
181
337
|
});
|
|
@@ -185,6 +341,114 @@ function resolveFiles(args: string[]): FileEntry[] {
|
|
|
185
341
|
return files;
|
|
186
342
|
}
|
|
187
343
|
|
|
344
|
+
// ─── Onboarding ──────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
const SETTINGS_PATH = join(os.homedir(), ".readit", "settings.json");
|
|
347
|
+
|
|
348
|
+
function isOnboarded(): boolean {
|
|
349
|
+
try {
|
|
350
|
+
const content = readFileSync(SETTINGS_PATH, "utf-8");
|
|
351
|
+
const settings = JSON.parse(content);
|
|
352
|
+
return settings.onboarded === true;
|
|
353
|
+
} catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function markOnboarded(): Promise<void> {
|
|
359
|
+
let settings: Record<string, unknown> = {};
|
|
360
|
+
try {
|
|
361
|
+
const content = readFileSync(SETTINGS_PATH, "utf-8");
|
|
362
|
+
settings = JSON.parse(content);
|
|
363
|
+
} catch {
|
|
364
|
+
// No existing settings
|
|
365
|
+
}
|
|
366
|
+
settings.onboarded = true;
|
|
367
|
+
const dir = join(os.homedir(), ".readit");
|
|
368
|
+
await fs.mkdir(dir, { recursive: true });
|
|
369
|
+
await fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const WELCOME_CONTENT = `# Welcome to readit
|
|
373
|
+
|
|
374
|
+
A simple tool for reviewing markdown with inline comments.
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## How It Works
|
|
379
|
+
|
|
380
|
+
readit follows a simple loop: **read → comment → extract**.
|
|
381
|
+
|
|
382
|
+
### 1. Read
|
|
383
|
+
|
|
384
|
+
You're already doing this. Open any markdown file with \`readit <file.md>\` and it renders in your browser with a clean reading experience.
|
|
385
|
+
|
|
386
|
+
### 2. Comment
|
|
387
|
+
|
|
388
|
+
Select any text to add a comment. Try it now — **select this sentence** and type your first comment.
|
|
389
|
+
|
|
390
|
+
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.
|
|
391
|
+
|
|
392
|
+
### 3. Extract
|
|
393
|
+
|
|
394
|
+
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.
|
|
395
|
+
|
|
396
|
+
You can also export as JSON if you prefer structured data.
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## Everything is Plain Markdown
|
|
401
|
+
|
|
402
|
+
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.
|
|
403
|
+
|
|
404
|
+
Each comment file looks something like this:
|
|
405
|
+
|
|
406
|
+
\`\`\`markdown
|
|
407
|
+
## Comment 1
|
|
408
|
+
**Selected:** "select this sentence"
|
|
409
|
+
**Comment:** This is my first comment!
|
|
410
|
+
**Created:** 2024-01-15T10:30:00Z
|
|
411
|
+
\`\`\`
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
## Navigating Comments
|
|
416
|
+
|
|
417
|
+
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:
|
|
418
|
+
|
|
419
|
+
| Shortcut | Action |
|
|
420
|
+
|----------|--------|
|
|
421
|
+
| \`Alt + ↑\` | Previous comment |
|
|
422
|
+
| \`Alt + ↓\` | Next comment |
|
|
423
|
+
| \`⌘ + C\` | Copy selected text (raw) |
|
|
424
|
+
| \`⌘ + Shift + C\` | Copy selected text with context (for AI) |
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## Quick Start
|
|
429
|
+
|
|
430
|
+
\`\`\`bash
|
|
431
|
+
# Review a markdown file
|
|
432
|
+
readit document.md
|
|
433
|
+
|
|
434
|
+
# Use a custom port
|
|
435
|
+
readit document.md --port 3000
|
|
436
|
+
|
|
437
|
+
# Start fresh (clear existing comments)
|
|
438
|
+
readit document.md --clean
|
|
439
|
+
\`\`\`
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Try It Now
|
|
444
|
+
|
|
445
|
+
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.
|
|
446
|
+
`;
|
|
447
|
+
|
|
448
|
+
const WELCOME_PATH = join(os.homedir(), ".readit", "welcome.md");
|
|
449
|
+
|
|
450
|
+
// ─── Program ─────────────────────────────────────────────────────────
|
|
451
|
+
|
|
188
452
|
program
|
|
189
453
|
.name("readit")
|
|
190
454
|
.description("Review Markdown and HTML documents with inline comments")
|
|
@@ -273,9 +537,9 @@ program
|
|
|
273
537
|
}
|
|
274
538
|
});
|
|
275
539
|
|
|
276
|
-
// Main review command (default) — accepts
|
|
540
|
+
// Main review command (default) — accepts zero or more files/directories
|
|
277
541
|
program
|
|
278
|
-
.argument("
|
|
542
|
+
.argument("[files...]", "Markdown or HTML files/directories to review")
|
|
279
543
|
.option("-p, --port <number>", "Port to run server on", "4567")
|
|
280
544
|
.option("--host <address>", "Host address to bind to", "127.0.0.1")
|
|
281
545
|
.option("--no-open", "Don't automatically open browser")
|
|
@@ -290,11 +554,27 @@ program
|
|
|
290
554
|
clean: boolean;
|
|
291
555
|
},
|
|
292
556
|
) => {
|
|
293
|
-
|
|
557
|
+
let files: FileEntry[];
|
|
294
558
|
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
559
|
+
if (fileArgs.length === 0) {
|
|
560
|
+
if (isOnboarded()) {
|
|
561
|
+
files = [];
|
|
562
|
+
} else {
|
|
563
|
+
files = [
|
|
564
|
+
{
|
|
565
|
+
content: WELCOME_CONTENT,
|
|
566
|
+
type: "markdown" as DocumentType,
|
|
567
|
+
filePath: WELCOME_PATH,
|
|
568
|
+
},
|
|
569
|
+
];
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
files = resolveFiles(fileArgs);
|
|
573
|
+
|
|
574
|
+
if (files.length === 0) {
|
|
575
|
+
console.error("error: no reviewable files found");
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
298
578
|
}
|
|
299
579
|
|
|
300
580
|
const preferredPort = Number.parseInt(options.port, 10);
|
|
@@ -316,9 +596,21 @@ program
|
|
|
316
596
|
clean: options.clean,
|
|
317
597
|
});
|
|
318
598
|
|
|
319
|
-
|
|
599
|
+
if (files.length === 0) {
|
|
600
|
+
console.log(`
|
|
601
|
+
readit - Document Review Tool
|
|
320
602
|
|
|
321
|
-
|
|
603
|
+
URL: ${url}
|
|
604
|
+
|
|
605
|
+
No files specified. Add files with:
|
|
606
|
+
readit open <file.md>
|
|
607
|
+
|
|
608
|
+
Server running. Press Ctrl+C to stop.
|
|
609
|
+
`);
|
|
610
|
+
} else {
|
|
611
|
+
const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
|
|
612
|
+
|
|
613
|
+
console.log(`
|
|
322
614
|
readit - Document Review Tool
|
|
323
615
|
|
|
324
616
|
${files.length === 1 ? "File:" : "Files:"}
|
|
@@ -328,11 +620,17 @@ ${fileList.join("\n")}
|
|
|
328
620
|
Server running. Close browser tab to stop.
|
|
329
621
|
Press Ctrl+C to force stop.
|
|
330
622
|
`);
|
|
623
|
+
}
|
|
331
624
|
|
|
332
625
|
if (options.open) {
|
|
333
626
|
open(url);
|
|
334
627
|
}
|
|
335
628
|
|
|
629
|
+
// Mark onboarding complete on first server start
|
|
630
|
+
if (fileArgs.length === 0) {
|
|
631
|
+
await markOnboarded();
|
|
632
|
+
}
|
|
633
|
+
|
|
336
634
|
// Graceful shutdown on Ctrl+C
|
|
337
635
|
process.on("SIGINT", async () => {
|
|
338
636
|
console.log("\n\nShutting down...");
|
|
@@ -362,13 +660,15 @@ program
|
|
|
362
660
|
// Resolve and validate files
|
|
363
661
|
const resolvedFiles: { path: string; type: DocumentType }[] = [];
|
|
364
662
|
for (const arg of fileArgs) {
|
|
365
|
-
const
|
|
663
|
+
const inputPath = resolve(process.cwd(), arg);
|
|
366
664
|
|
|
367
|
-
if (!existsSync(
|
|
368
|
-
console.error(`error: not found: ${
|
|
665
|
+
if (!existsSync(inputPath)) {
|
|
666
|
+
console.error(`error: not found: ${inputPath}`);
|
|
369
667
|
process.exit(1);
|
|
370
668
|
}
|
|
371
669
|
|
|
670
|
+
const filePath = realpathSync(inputPath);
|
|
671
|
+
|
|
372
672
|
const type = getFileType(filePath);
|
|
373
673
|
if (!type) {
|
|
374
674
|
console.error(
|
|
@@ -380,59 +680,27 @@ program
|
|
|
380
680
|
resolvedFiles.push({ path: filePath, type });
|
|
381
681
|
}
|
|
382
682
|
|
|
383
|
-
// Try to find running server
|
|
384
|
-
const server = await discoverServer();
|
|
385
|
-
|
|
386
|
-
if (server) {
|
|
387
|
-
// Send files to running server
|
|
388
|
-
for (const file of resolvedFiles) {
|
|
389
|
-
try {
|
|
390
|
-
const res = await fetch(
|
|
391
|
-
`http://127.0.0.1:${server.port}/api/files`,
|
|
392
|
-
{
|
|
393
|
-
method: "POST",
|
|
394
|
-
headers: { "Content-Type": "application/json" },
|
|
395
|
-
body: JSON.stringify({ path: file.path }),
|
|
396
|
-
},
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
if (!res.ok) {
|
|
400
|
-
const data = await res.json();
|
|
401
|
-
console.error(`error: failed to add ${file.path}: ${data.error}`);
|
|
402
|
-
process.exit(1);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const data = await res.json();
|
|
406
|
-
console.log(`Added: ${data.fileName} (${data.type})`);
|
|
407
|
-
} catch (err) {
|
|
408
|
-
console.error(
|
|
409
|
-
"error: failed to connect to server:",
|
|
410
|
-
err instanceof Error ? err.message : err,
|
|
411
|
-
);
|
|
412
|
-
process.exit(1);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
console.log(`\nServer: http://127.0.0.1:${server.port}`);
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// No running server — start one
|
|
421
|
-
console.log("No running server found, starting new one...\n");
|
|
422
|
-
|
|
423
683
|
const files = resolvedFiles.map((f) => ({
|
|
424
|
-
content: readFileSync(f.path, "utf-8"),
|
|
425
684
|
type: f.type,
|
|
426
685
|
filePath: f.path,
|
|
427
686
|
}));
|
|
428
687
|
|
|
429
688
|
const preferredPort = Number.parseInt(options.port, 10);
|
|
430
689
|
try {
|
|
431
|
-
const
|
|
690
|
+
const target = await getServerTarget(
|
|
432
691
|
files,
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
692
|
+
preferredPort,
|
|
693
|
+
options.host,
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
if (target.kind === "existing") {
|
|
697
|
+
await attachFiles(
|
|
698
|
+
{ port: target.port, pid: process.pid },
|
|
699
|
+
resolvedFiles,
|
|
700
|
+
);
|
|
701
|
+
console.log(`\nServer: ${target.url}`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
436
704
|
|
|
437
705
|
const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
|
|
438
706
|
console.log(`
|
|
@@ -440,17 +708,17 @@ readit - Document Review Tool
|
|
|
440
708
|
|
|
441
709
|
${files.length === 1 ? "File:" : "Files:"}
|
|
442
710
|
${fileList.join("\n")}
|
|
443
|
-
URL: ${url}
|
|
711
|
+
URL: ${target.url}
|
|
444
712
|
|
|
445
713
|
Server running. Close browser tab to stop.
|
|
446
714
|
Press Ctrl+C to force stop.
|
|
447
715
|
`);
|
|
448
716
|
|
|
449
|
-
open(url);
|
|
717
|
+
open(target.url);
|
|
450
718
|
|
|
451
719
|
process.on("SIGINT", async () => {
|
|
452
720
|
console.log("\n\nShutting down...");
|
|
453
|
-
|
|
721
|
+
target.server?.stop();
|
|
454
722
|
await removeServerInfo();
|
|
455
723
|
process.exit(0);
|
|
456
724
|
});
|