@peaske7/readit 0.1.8 → 0.2.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/README.md +0 -3
- package/biome.json +1 -1
- package/bun.lock +43 -185
- package/docs/perf-baseline.md +75 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- package/e2e/perf/add-comment.spec.ts +118 -0
- package/e2e/perf/fixtures/generate.ts +331 -0
- package/e2e/perf/initial-load.spec.ts +49 -0
- package/e2e/perf/perf.setup.ts +23 -0
- package/e2e/perf/perf.teardown.ts +9 -0
- package/e2e/perf/scroll.spec.ts +39 -0
- package/e2e/perf/tab-switch.spec.ts +69 -0
- package/e2e/perf/text-selection.spec.ts +119 -0
- package/e2e/perf/utils/metrics.ts +286 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- package/package.json +9 -18
- package/playwright.config.ts +12 -0
- package/src/App.tsx +124 -172
- package/src/{cli/index.ts → cli.ts} +37 -53
- package/src/components/ActionsMenu.tsx +6 -27
- package/src/components/DocumentViewer/DocumentViewer.tsx +77 -106
- package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
- package/src/components/Header.tsx +9 -20
- package/src/components/InlineEditor.tsx +5 -5
- package/src/components/MarginNote.tsx +71 -93
- package/src/components/MarginNotes.tsx +7 -34
- package/src/components/RawModal.tsx +9 -8
- package/src/components/ReanchorConfirm.tsx +2 -2
- package/src/components/SettingsModal.tsx +11 -89
- package/src/components/TabBar.tsx +4 -4
- package/src/components/TableOfContents.tsx +5 -5
- package/src/components/comments/CommentInput.tsx +7 -35
- package/src/components/comments/CommentListItem.tsx +9 -11
- package/src/components/comments/CommentManager.tsx +53 -37
- package/src/components/comments/CommentNav.tsx +14 -14
- package/src/components/ui/ActionLink.tsx +14 -18
- package/src/components/ui/Button.tsx +42 -43
- package/src/components/ui/Dialog.tsx +73 -113
- package/src/components/ui/DropdownMenu.tsx +113 -69
- package/src/components/ui/Text.tsx +30 -37
- package/src/contexts/CommentContext.tsx +75 -106
- package/src/contexts/LocaleContext.tsx +45 -4
- package/src/contexts/PositionsContext.tsx +16 -0
- package/src/contexts/SettingsContext.tsx +133 -0
- package/src/hooks/useClickOutside.ts +0 -4
- package/src/hooks/useCommentNavigation.ts +6 -29
- package/src/hooks/useComments.ts +6 -18
- package/src/hooks/useDocument.ts +35 -34
- package/src/hooks/useHeadings.test.ts +8 -50
- package/src/hooks/useHeadings.ts +5 -88
- package/src/hooks/useScrollSpy.ts +10 -14
- package/src/hooks/useTextSelection.ts +1 -38
- package/src/lib/__fixtures__/bench-data.ts +1 -41
- package/src/lib/anchor.bench.ts +57 -67
- package/src/lib/anchor.test.ts +5 -1
- package/src/lib/anchor.ts +13 -93
- package/src/lib/comment-storage.test.ts +4 -4
- package/src/lib/comment-storage.ts +2 -46
- package/src/lib/export.ts +7 -13
- package/src/lib/highlight/core.test.ts +1 -1
- package/src/lib/highlight/dom.ts +5 -68
- package/src/lib/highlight/highlighter.ts +102 -262
- package/src/lib/highlight/resolver.ts +112 -0
- package/src/lib/highlight/types.ts +0 -35
- package/src/lib/highlight/worker.ts +45 -0
- package/src/lib/i18n/en.ts +1 -50
- package/src/lib/i18n/ja.ts +1 -50
- package/src/lib/i18n/types.ts +1 -49
- package/src/lib/margin-layout.ts +5 -27
- package/src/lib/positions.ts +150 -0
- package/src/lib/utils.ts +2 -19
- package/src/schema.ts +81 -0
- package/src/{server/index.ts → server.ts} +74 -74
- package/src/{store/index.ts → store.ts} +14 -46
- package/vite.config.ts +8 -0
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- package/src/hooks/useKeybindings.ts +0 -108
- package/src/hooks/useKeyboardShortcuts.ts +0 -63
- package/src/hooks/useLayoutMode.ts +0 -44
- package/src/hooks/useLocalePreference.ts +0 -42
- package/src/hooks/useReanchorMode.ts +0 -33
- package/src/hooks/useScrollMetrics.ts +0 -56
- package/src/hooks/useThemePreference.ts +0 -66
- package/src/lib/comment-storage.bench.ts +0 -63
- package/src/lib/context.bench.ts +0 -41
- package/src/lib/context.test.ts +0 -224
- package/src/lib/context.ts +0 -193
- package/src/lib/editor-links.ts +0 -59
- package/src/lib/export.bench.ts +0 -35
- package/src/lib/highlight/colors.ts +0 -37
- package/src/lib/highlight/core.ts +0 -54
- package/src/lib/highlight/index.ts +0 -23
- package/src/lib/highlight/script-builder.ts +0 -485
- package/src/lib/html-processor.test.tsx +0 -170
- package/src/lib/html-processor.tsx +0 -95
- package/src/lib/i18n/completeness.test.ts +0 -51
- package/src/lib/i18n/translations.test.ts +0 -39
- package/src/lib/layout-constants.ts +0 -12
- package/src/lib/margin-layout.bench.ts +0 -28
- package/src/lib/scroll.test.ts +0 -118
- package/src/lib/scroll.ts +0 -47
- package/src/lib/shortcut-registry.test.ts +0 -173
- package/src/lib/shortcut-registry.ts +0 -209
- package/src/lib/utils.test.ts +0 -110
- package/src/store/index.test.ts +0 -242
- package/src/types/index.ts +0 -127
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { resolveMarginNotePositions } from "./margin-layout";
|
|
2
|
+
|
|
3
|
+
type Listener = () => void;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Positions managed outside React. Scroll-invariant — only recalculates
|
|
7
|
+
* on highlight mutation (MutationObserver) and resize.
|
|
8
|
+
*/
|
|
9
|
+
export class Positions {
|
|
10
|
+
private relative = new Map<string, number>();
|
|
11
|
+
private absolute = new Map<string, number>();
|
|
12
|
+
private snapshot: Record<string, number> = {};
|
|
13
|
+
private notes = new Map<string, HTMLElement>();
|
|
14
|
+
private ids: string[] = [];
|
|
15
|
+
private pendingTop: number | undefined;
|
|
16
|
+
private listeners = new Set<Listener>();
|
|
17
|
+
private root: HTMLElement | null = null;
|
|
18
|
+
private container: HTMLElement | null = null;
|
|
19
|
+
private resizeRaf: number | null = null;
|
|
20
|
+
private mutationRaf: number | null = null;
|
|
21
|
+
private observer: MutationObserver | null = null;
|
|
22
|
+
|
|
23
|
+
attach(root: HTMLElement, container: HTMLElement) {
|
|
24
|
+
this.root = root;
|
|
25
|
+
this.container = container;
|
|
26
|
+
window.addEventListener("resize", this.onResize);
|
|
27
|
+
|
|
28
|
+
this.observer = new MutationObserver(() => {
|
|
29
|
+
if (this.mutationRaf !== null) return;
|
|
30
|
+
this.mutationRaf = requestAnimationFrame(() => {
|
|
31
|
+
this.mutationRaf = null;
|
|
32
|
+
this.cache();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
this.observer.observe(root, { childList: true, subtree: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
detach() {
|
|
39
|
+
window.removeEventListener("resize", this.onResize);
|
|
40
|
+
if (this.resizeRaf !== null) cancelAnimationFrame(this.resizeRaf);
|
|
41
|
+
if (this.mutationRaf !== null) cancelAnimationFrame(this.mutationRaf);
|
|
42
|
+
this.resizeRaf = null;
|
|
43
|
+
this.mutationRaf = null;
|
|
44
|
+
this.observer?.disconnect();
|
|
45
|
+
this.observer = null;
|
|
46
|
+
this.root = null;
|
|
47
|
+
this.container = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cache() {
|
|
51
|
+
if (!this.root || !this.container) return;
|
|
52
|
+
|
|
53
|
+
const ref = this.container.getBoundingClientRect();
|
|
54
|
+
const scrollY = window.scrollY;
|
|
55
|
+
|
|
56
|
+
this.relative.clear();
|
|
57
|
+
this.absolute.clear();
|
|
58
|
+
|
|
59
|
+
for (const mark of this.root.querySelectorAll("mark[data-comment-id]")) {
|
|
60
|
+
const id = mark.getAttribute("data-comment-id");
|
|
61
|
+
if (!id || this.relative.has(id)) continue;
|
|
62
|
+
|
|
63
|
+
const rect = mark.getBoundingClientRect();
|
|
64
|
+
this.relative.set(id, rect.top - ref.top);
|
|
65
|
+
this.absolute.set(id, rect.top + scrollY);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const snap: Record<string, number> = {};
|
|
69
|
+
for (const [id, top] of this.absolute) snap[id] = top;
|
|
70
|
+
this.snapshot = snap;
|
|
71
|
+
|
|
72
|
+
this.apply();
|
|
73
|
+
this.notify();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setIds(ids: string[]) {
|
|
77
|
+
this.ids = ids;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setPending(top: number | undefined) {
|
|
81
|
+
if (this.pendingTop === top) return;
|
|
82
|
+
this.pendingTop = top;
|
|
83
|
+
this.apply();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
register(id: string, el: HTMLElement) {
|
|
87
|
+
this.notes.set(id, el);
|
|
88
|
+
const top = this.resolve().get(id);
|
|
89
|
+
if (top !== undefined) {
|
|
90
|
+
el.style.top = `${top}px`;
|
|
91
|
+
el.style.visibility = "visible";
|
|
92
|
+
} else {
|
|
93
|
+
el.style.visibility = "hidden";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
unregister(id: string) {
|
|
98
|
+
this.notes.delete(id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getAbsolute(): Record<string, number> {
|
|
102
|
+
return this.snapshot;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
subscribe(fn: Listener): () => void {
|
|
106
|
+
this.listeners.add(fn);
|
|
107
|
+
return () => this.listeners.delete(fn);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
dispose() {
|
|
111
|
+
this.detach();
|
|
112
|
+
this.relative.clear();
|
|
113
|
+
this.absolute.clear();
|
|
114
|
+
this.snapshot = {};
|
|
115
|
+
this.notes.clear();
|
|
116
|
+
this.listeners.clear();
|
|
117
|
+
this.ids = [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private resolve(): Map<string, number> {
|
|
121
|
+
const pos: Record<string, number> = {};
|
|
122
|
+
for (const [id, top] of this.relative) pos[id] = top;
|
|
123
|
+
return resolveMarginNotePositions(this.ids, pos, this.pendingTop);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private apply() {
|
|
127
|
+
const resolved = this.resolve();
|
|
128
|
+
for (const [id, el] of this.notes) {
|
|
129
|
+
const top = resolved.get(id);
|
|
130
|
+
if (top !== undefined) {
|
|
131
|
+
el.style.top = `${top}px`;
|
|
132
|
+
el.style.visibility = "visible";
|
|
133
|
+
} else {
|
|
134
|
+
el.style.visibility = "hidden";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private onResize = () => {
|
|
140
|
+
if (this.resizeRaf !== null) return;
|
|
141
|
+
this.resizeRaf = requestAnimationFrame(() => {
|
|
142
|
+
this.resizeRaf = null;
|
|
143
|
+
this.cache();
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
private notify() {
|
|
148
|
+
for (const fn of this.listeners) fn();
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/lib/utils.ts
CHANGED
|
@@ -1,34 +1,20 @@
|
|
|
1
1
|
import { type ClassValue, clsx } from "clsx";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { twMerge } from "tailwind-merge";
|
|
4
|
-
import type { DocumentType } from "../types";
|
|
5
4
|
|
|
6
|
-
export function
|
|
7
|
-
|
|
8
|
-
return "markdown";
|
|
9
|
-
}
|
|
10
|
-
if (filePath.endsWith(".html") || filePath.endsWith(".htm")) {
|
|
11
|
-
return "html";
|
|
12
|
-
}
|
|
13
|
-
return null;
|
|
5
|
+
export function isMarkdownFile(filePath: string): boolean {
|
|
6
|
+
return filePath.endsWith(".md") || filePath.endsWith(".markdown");
|
|
14
7
|
}
|
|
15
8
|
|
|
16
9
|
export function cn(...inputs: ReadonlyArray<ClassValue>) {
|
|
17
10
|
return twMerge(clsx(inputs));
|
|
18
11
|
}
|
|
19
12
|
|
|
20
|
-
/**
|
|
21
|
-
* Truncate text with ellipsis for toast notifications.
|
|
22
|
-
*/
|
|
23
13
|
export function truncate(text: string, maxLength = 30): string {
|
|
24
14
|
if (text.length <= maxLength) return text;
|
|
25
15
|
return `${text.slice(0, maxLength)}…`;
|
|
26
16
|
}
|
|
27
17
|
|
|
28
|
-
/**
|
|
29
|
-
* Recursively extract text content from React children.
|
|
30
|
-
* Handles strings, numbers, arrays, and React elements.
|
|
31
|
-
*/
|
|
32
18
|
export function getTextContent(children: ReactNode): string {
|
|
33
19
|
if (typeof children === "string" || typeof children === "number") {
|
|
34
20
|
return String(children);
|
|
@@ -48,9 +34,6 @@ export function getTextContent(children: ReactNode): string {
|
|
|
48
34
|
return "";
|
|
49
35
|
}
|
|
50
36
|
|
|
51
|
-
/**
|
|
52
|
-
* Slugify text to create URL-friendly IDs
|
|
53
|
-
*/
|
|
54
37
|
export function slugify(text: string): string {
|
|
55
38
|
return text
|
|
56
39
|
.toLowerCase()
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const AnchorConfidences = {
|
|
2
|
+
EXACT: "exact",
|
|
3
|
+
NORMALIZED: "normalized",
|
|
4
|
+
FUZZY: "fuzzy",
|
|
5
|
+
UNRESOLVED: "unresolved",
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export type AnchorConfidence =
|
|
9
|
+
(typeof AnchorConfidences)[keyof typeof AnchorConfidences];
|
|
10
|
+
|
|
11
|
+
export type ResolvedAnchorConfidence = Exclude<
|
|
12
|
+
AnchorConfidence,
|
|
13
|
+
typeof AnchorConfidences.UNRESOLVED
|
|
14
|
+
>;
|
|
15
|
+
|
|
16
|
+
export interface Comment {
|
|
17
|
+
id: string;
|
|
18
|
+
selectedText: string;
|
|
19
|
+
comment: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
startOffset: number;
|
|
22
|
+
endOffset: number;
|
|
23
|
+
/** e.g. "L42" or "L42-L55" */
|
|
24
|
+
lineHint?: string;
|
|
25
|
+
anchorConfidence?: AnchorConfidence;
|
|
26
|
+
/** First N chars of original text for anchor matching when selectedText is truncated */
|
|
27
|
+
anchorPrefix?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CommentFile {
|
|
31
|
+
source: string;
|
|
32
|
+
/** SHA-256 prefix (16 chars) */
|
|
33
|
+
hash: string;
|
|
34
|
+
version: number;
|
|
35
|
+
comments: Comment[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Anchor {
|
|
39
|
+
start: number;
|
|
40
|
+
end: number;
|
|
41
|
+
line: number;
|
|
42
|
+
confidence: ResolvedAnchorConfidence;
|
|
43
|
+
distance?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SelectionRange {
|
|
47
|
+
startOffset: number;
|
|
48
|
+
endOffset: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Selection extends SelectionRange {
|
|
52
|
+
text: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Document {
|
|
56
|
+
content: string;
|
|
57
|
+
filePath: string;
|
|
58
|
+
fileName: string;
|
|
59
|
+
clean: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const FontFamilies = {
|
|
63
|
+
SERIF: "serif",
|
|
64
|
+
SANS_SERIF: "sans-serif",
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
export type FontFamily = (typeof FontFamilies)[keyof typeof FontFamilies];
|
|
68
|
+
|
|
69
|
+
export const ThemeModes = {
|
|
70
|
+
LIGHT: "light",
|
|
71
|
+
DARK: "dark",
|
|
72
|
+
SYSTEM: "system",
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
export type ThemeMode = (typeof ThemeModes)[keyof typeof ThemeModes];
|
|
76
|
+
|
|
77
|
+
export interface DocumentSettings {
|
|
78
|
+
version: number;
|
|
79
|
+
fontFamily: FontFamily;
|
|
80
|
+
onboarded?: boolean;
|
|
81
|
+
}
|
|
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { basename, dirname, join } from "node:path";
|
|
6
|
-
import { findAnchorWithFallback } from "
|
|
6
|
+
import { findAnchorWithFallback } from "./lib/anchor.js";
|
|
7
7
|
import {
|
|
8
8
|
computeHash,
|
|
9
9
|
createComment,
|
|
@@ -12,20 +12,15 @@ import {
|
|
|
12
12
|
parseCommentFile,
|
|
13
13
|
serializeComments,
|
|
14
14
|
truncateSelection,
|
|
15
|
-
} from "
|
|
16
|
-
import {
|
|
15
|
+
} from "./lib/comment-storage.js";
|
|
16
|
+
import { isMarkdownFile } from "./lib/utils.js";
|
|
17
17
|
import {
|
|
18
18
|
AnchorConfidences,
|
|
19
19
|
type Comment,
|
|
20
20
|
type DocumentSettings,
|
|
21
|
-
type DocumentType,
|
|
22
|
-
type EditorScheme,
|
|
23
|
-
EditorSchemes,
|
|
24
21
|
FontFamilies,
|
|
25
22
|
type FontFamily,
|
|
26
|
-
} from "
|
|
27
|
-
|
|
28
|
-
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
23
|
+
} from "./schema.js";
|
|
29
24
|
|
|
30
25
|
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
31
26
|
return err instanceof Error && "code" in err;
|
|
@@ -33,7 +28,6 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
|
33
28
|
|
|
34
29
|
export interface FileEntry {
|
|
35
30
|
content?: string;
|
|
36
|
-
type: DocumentType;
|
|
37
31
|
filePath: string;
|
|
38
32
|
}
|
|
39
33
|
|
|
@@ -194,12 +188,6 @@ function isValidFontFamily(value: unknown): value is FontFamily {
|
|
|
194
188
|
return value === FontFamilies.SERIF || value === FontFamilies.SANS_SERIF;
|
|
195
189
|
}
|
|
196
190
|
|
|
197
|
-
function isValidEditorScheme(value: unknown): value is EditorScheme {
|
|
198
|
-
return Object.values(EditorSchemes).includes(value as EditorScheme);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ─── PID file helpers ───────────────────────────────────────────────
|
|
202
|
-
|
|
203
191
|
export const SERVER_INFO_PATH = path.join(
|
|
204
192
|
os.homedir(),
|
|
205
193
|
".readit",
|
|
@@ -225,8 +213,6 @@ export async function removeServerInfo(): Promise<void> {
|
|
|
225
213
|
}
|
|
226
214
|
}
|
|
227
215
|
|
|
228
|
-
// ─── Response helpers ───────────────────────────────────────────────
|
|
229
|
-
|
|
230
216
|
function json(data: unknown, status = 200): Response {
|
|
231
217
|
return Response.json(data, { status });
|
|
232
218
|
}
|
|
@@ -235,15 +221,11 @@ function errorResponse(message: string, status: number): Response {
|
|
|
235
221
|
return Response.json({ error: message }, { status });
|
|
236
222
|
}
|
|
237
223
|
|
|
238
|
-
// ─── Route context ──────────────────────────────────────────────────
|
|
239
|
-
|
|
240
224
|
interface RouteContext {
|
|
241
225
|
filePath: string;
|
|
242
226
|
getCurrentContent: () => Promise<string>;
|
|
243
227
|
}
|
|
244
228
|
|
|
245
|
-
// ─── Route handlers ─────────────────────────────────────────────────
|
|
246
|
-
|
|
247
229
|
async function getComments(ctx: RouteContext): Promise<Response> {
|
|
248
230
|
try {
|
|
249
231
|
const currentContent = await ctx.getCurrentContent();
|
|
@@ -446,22 +428,16 @@ async function getSettingsRoute(): Promise<Response> {
|
|
|
446
428
|
async function updateSettingsRoute(req: Request): Promise<Response> {
|
|
447
429
|
try {
|
|
448
430
|
const body = await req.json();
|
|
449
|
-
const { fontFamily
|
|
431
|
+
const { fontFamily } = body;
|
|
450
432
|
|
|
451
433
|
if (fontFamily !== undefined && !isValidFontFamily(fontFamily)) {
|
|
452
434
|
return errorResponse("Invalid font family", 400);
|
|
453
435
|
}
|
|
454
436
|
|
|
455
|
-
if (editorScheme !== undefined && !isValidEditorScheme(editorScheme)) {
|
|
456
|
-
return errorResponse("Invalid editor scheme", 400);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
437
|
const current = await readSettings();
|
|
460
438
|
const settings: DocumentSettings = {
|
|
461
439
|
...current,
|
|
462
440
|
...(fontFamily !== undefined && { fontFamily }),
|
|
463
|
-
...(editorScheme !== undefined && { editorScheme }),
|
|
464
|
-
...(keybindings !== undefined && { keybindings }),
|
|
465
441
|
};
|
|
466
442
|
|
|
467
443
|
await writeSettings(settings);
|
|
@@ -472,8 +448,6 @@ async function updateSettingsRoute(req: Request): Promise<Response> {
|
|
|
472
448
|
}
|
|
473
449
|
}
|
|
474
450
|
|
|
475
|
-
// ─── SSE helpers ────────────────────────────────────────────────────
|
|
476
|
-
|
|
477
451
|
function createDocumentStream(
|
|
478
452
|
sseClients: Set<ReadableStreamDefaultController>,
|
|
479
453
|
): Response {
|
|
@@ -545,8 +519,6 @@ function createHeartbeat(
|
|
|
545
519
|
});
|
|
546
520
|
}
|
|
547
521
|
|
|
548
|
-
// ─── Static file serving ────────────────────────────────────────────
|
|
549
|
-
|
|
550
522
|
async function serveStaticFile(
|
|
551
523
|
distPath: string,
|
|
552
524
|
pathname: string,
|
|
@@ -567,40 +539,85 @@ async function serveStaticFile(
|
|
|
567
539
|
return new Response("Not Found", { status: 404 });
|
|
568
540
|
}
|
|
569
541
|
|
|
570
|
-
|
|
542
|
+
const VITE_DEV_PORT = 24678;
|
|
543
|
+
const VITE_DEV_ORIGIN = `http://127.0.0.1:${VITE_DEV_PORT}`;
|
|
544
|
+
|
|
545
|
+
async function proxyToVite(
|
|
546
|
+
req: Request,
|
|
547
|
+
pathname: string,
|
|
548
|
+
search: string,
|
|
549
|
+
): Promise<Response> {
|
|
550
|
+
const target = `${VITE_DEV_ORIGIN}${pathname}${search}`;
|
|
551
|
+
try {
|
|
552
|
+
return await fetch(
|
|
553
|
+
new Request(target, {
|
|
554
|
+
method: req.method,
|
|
555
|
+
headers: req.headers,
|
|
556
|
+
body: req.body,
|
|
557
|
+
redirect: "manual",
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
} catch {
|
|
561
|
+
return new Response("Vite dev server not available", { status: 502 });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function isViteReady(): Promise<boolean> {
|
|
566
|
+
try {
|
|
567
|
+
const res = await fetch(`${VITE_DEV_ORIGIN}/`);
|
|
568
|
+
return res.ok;
|
|
569
|
+
} catch {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function spawnViteDev(): Promise<() => void> {
|
|
575
|
+
// If Vite is already running (e.g. after bun --watch restart), reuse it
|
|
576
|
+
if (await isViteReady()) {
|
|
577
|
+
return () => {};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const child = Bun.spawn(
|
|
581
|
+
["bunx", "vite", "--port", String(VITE_DEV_PORT), "--strictPort"],
|
|
582
|
+
{ stdout: "ignore", stderr: "inherit" },
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
const maxWaitMs = 10_000;
|
|
586
|
+
const start = Date.now();
|
|
587
|
+
while (Date.now() - start < maxWaitMs) {
|
|
588
|
+
if (await isViteReady()) break;
|
|
589
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return () => {
|
|
593
|
+
child.kill();
|
|
594
|
+
};
|
|
595
|
+
}
|
|
571
596
|
|
|
572
597
|
function extractCommentId(pathname: string): string | undefined {
|
|
573
598
|
const match = pathname.match(/^\/api\/comments\/([^/]+)/);
|
|
574
599
|
return match?.[1];
|
|
575
600
|
}
|
|
576
601
|
|
|
577
|
-
// ─── Multi-file state ───────────────────────────────────────────────
|
|
578
|
-
|
|
579
602
|
interface FileState {
|
|
580
603
|
content: string | null;
|
|
581
604
|
isLoaded: boolean;
|
|
582
|
-
type: DocumentType;
|
|
583
605
|
debounceTimer: ReturnType<typeof setTimeout> | null;
|
|
584
606
|
}
|
|
585
607
|
|
|
586
|
-
// ─── Server creation ────────────────────────────────────────────────
|
|
587
|
-
|
|
588
608
|
interface ServerWithWatchers {
|
|
589
609
|
server: ReturnType<typeof Bun.serve>;
|
|
590
610
|
watchers: FSWatcher[];
|
|
591
611
|
}
|
|
592
612
|
|
|
593
613
|
function createServer(options: ServerOptions): ServerWithWatchers {
|
|
594
|
-
// Map of absolute path → mutable file state
|
|
595
614
|
const fileMap = new Map<string, FileState>();
|
|
596
|
-
// Ordered list of file paths (insertion order for tab display)
|
|
597
615
|
const fileOrder: string[] = [];
|
|
598
616
|
|
|
599
617
|
for (const entry of options.files) {
|
|
600
618
|
fileMap.set(entry.filePath, {
|
|
601
619
|
content: entry.content ?? null,
|
|
602
620
|
isLoaded: entry.content !== undefined,
|
|
603
|
-
type: entry.type,
|
|
604
621
|
debounceTimer: null,
|
|
605
622
|
});
|
|
606
623
|
fileOrder.push(entry.filePath);
|
|
@@ -663,7 +680,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
663
680
|
return content;
|
|
664
681
|
}
|
|
665
682
|
|
|
666
|
-
// Resolve the target file from ?path= query param, falling back to first file
|
|
667
683
|
function resolveContext(url: URL): RouteContext | null {
|
|
668
684
|
const requestedPath = url.searchParams.get("path") ?? defaultPath;
|
|
669
685
|
const state = fileMap.get(requestedPath);
|
|
@@ -726,18 +742,11 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
726
742
|
const { pathname } = url;
|
|
727
743
|
const method = req.method;
|
|
728
744
|
|
|
729
|
-
// ── API routes ──────────────────────────────────────────
|
|
730
|
-
|
|
731
|
-
// Document list (multi-file)
|
|
732
745
|
if (pathname === "/api/documents" && method === "GET") {
|
|
733
|
-
const files = fileOrder.map((fp) => {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
fileName: basename(fp),
|
|
738
|
-
type: state.type,
|
|
739
|
-
};
|
|
740
|
-
});
|
|
746
|
+
const files = fileOrder.map((fp) => ({
|
|
747
|
+
path: fp,
|
|
748
|
+
fileName: basename(fp),
|
|
749
|
+
}));
|
|
741
750
|
return json({
|
|
742
751
|
files,
|
|
743
752
|
clean: options.clean || false,
|
|
@@ -745,7 +754,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
745
754
|
});
|
|
746
755
|
}
|
|
747
756
|
|
|
748
|
-
// Register a document for this session without forcing focus
|
|
749
757
|
if (pathname === "/api/documents" && method === "POST") {
|
|
750
758
|
try {
|
|
751
759
|
const { path: requestedPath } = await req.json();
|
|
@@ -763,11 +771,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
763
771
|
}
|
|
764
772
|
throw err;
|
|
765
773
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
if (!fileType) {
|
|
774
|
+
if (!isMarkdownFile(filePath)) {
|
|
769
775
|
return errorResponse(
|
|
770
|
-
`Unsupported file type: ${filePath} (expected .md
|
|
776
|
+
`Unsupported file type: ${filePath} (expected .md or .markdown)`,
|
|
771
777
|
400,
|
|
772
778
|
);
|
|
773
779
|
}
|
|
@@ -778,15 +784,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
778
784
|
return json({
|
|
779
785
|
path: filePath,
|
|
780
786
|
fileName: basename(filePath),
|
|
781
|
-
type: fileType,
|
|
782
787
|
status: "present",
|
|
783
788
|
});
|
|
784
789
|
} else {
|
|
785
|
-
// New document — register metadata only, load content on demand
|
|
786
790
|
fileMap.set(filePath, {
|
|
787
791
|
content: null,
|
|
788
792
|
isLoaded: false,
|
|
789
|
-
type: fileType,
|
|
790
793
|
debounceTimer: null,
|
|
791
794
|
});
|
|
792
795
|
fileOrder.push(filePath);
|
|
@@ -798,14 +801,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
798
801
|
type: "document-added",
|
|
799
802
|
path: filePath,
|
|
800
803
|
fileName: basename(filePath),
|
|
801
|
-
fileType,
|
|
802
804
|
});
|
|
803
805
|
}
|
|
804
806
|
|
|
805
807
|
return json({
|
|
806
808
|
path: filePath,
|
|
807
809
|
fileName: basename(filePath),
|
|
808
|
-
type: fileType,
|
|
809
810
|
status: "added",
|
|
810
811
|
});
|
|
811
812
|
} catch (err) {
|
|
@@ -814,15 +815,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
814
815
|
}
|
|
815
816
|
}
|
|
816
817
|
|
|
817
|
-
// Single document (backward compat + path-aware)
|
|
818
818
|
if (pathname === "/api/document" && method === "GET") {
|
|
819
819
|
const ctxOrRes = requireContext(url);
|
|
820
820
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
821
|
-
const state = fileMap.get(ctxOrRes.filePath)!;
|
|
822
821
|
const content = await ctxOrRes.getCurrentContent();
|
|
823
822
|
return json({
|
|
824
823
|
content,
|
|
825
|
-
type: state.type,
|
|
826
824
|
filePath: ctxOrRes.filePath,
|
|
827
825
|
fileName: basename(ctxOrRes.filePath),
|
|
828
826
|
clean: options.clean || false,
|
|
@@ -841,7 +839,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
841
839
|
return createHeartbeat(onHeartbeatOpen, onHeartbeatClose);
|
|
842
840
|
}
|
|
843
841
|
|
|
844
|
-
// Comments routes
|
|
845
842
|
if (pathname === "/api/comments" && method === "GET") {
|
|
846
843
|
const ctxOrRes = requireContext(url);
|
|
847
844
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
@@ -866,7 +863,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
866
863
|
return clearComments(ctxOrRes);
|
|
867
864
|
}
|
|
868
865
|
|
|
869
|
-
// Parameterized comment routes
|
|
870
866
|
const commentId = extractCommentId(pathname);
|
|
871
867
|
if (commentId) {
|
|
872
868
|
const ctxOrRes = requireContext(url);
|
|
@@ -883,7 +879,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
883
879
|
}
|
|
884
880
|
}
|
|
885
881
|
|
|
886
|
-
// Settings routes (global, not per-document)
|
|
887
882
|
if (pathname === "/api/settings" && method === "GET") {
|
|
888
883
|
return getSettingsRoute();
|
|
889
884
|
}
|
|
@@ -892,8 +887,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
892
887
|
return updateSettingsRoute(req);
|
|
893
888
|
}
|
|
894
889
|
|
|
895
|
-
|
|
896
|
-
|
|
890
|
+
if (isDev) {
|
|
891
|
+
return proxyToVite(req, pathname, url.search);
|
|
892
|
+
}
|
|
897
893
|
return serveStaticFile(distPath, pathname);
|
|
898
894
|
},
|
|
899
895
|
});
|
|
@@ -909,8 +905,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
909
905
|
return { server, watchers };
|
|
910
906
|
}
|
|
911
907
|
|
|
912
|
-
// ─── Port fallback + start ──────────────────────────────────────────
|
|
913
|
-
|
|
914
908
|
export async function startServer(
|
|
915
909
|
options: ServerOptions,
|
|
916
910
|
): Promise<ServerResult> {
|
|
@@ -923,9 +917,15 @@ export async function startServer(
|
|
|
923
917
|
const displayHost =
|
|
924
918
|
options.host === "0.0.0.0" ? "localhost" : options.host;
|
|
925
919
|
|
|
920
|
+
let stopVite: (() => void) | undefined;
|
|
921
|
+
if (process.env.NODE_ENV === "development") {
|
|
922
|
+
stopVite = await spawnViteDev();
|
|
923
|
+
}
|
|
924
|
+
|
|
926
925
|
const originalStop = server.stop.bind(server);
|
|
927
926
|
const wrappedServer = {
|
|
928
927
|
stop() {
|
|
928
|
+
stopVite?.();
|
|
929
929
|
for (const w of watchers) w.close();
|
|
930
930
|
originalStop();
|
|
931
931
|
},
|