@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
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef } from "react";
|
|
2
|
-
import {
|
|
3
|
-
matchesBinding,
|
|
4
|
-
type ShortcutAction,
|
|
5
|
-
type ShortcutDefinition,
|
|
6
|
-
} from "../lib/shortcut-registry";
|
|
7
|
-
|
|
8
|
-
type ActionMap = Partial<Record<ShortcutAction, () => void>>;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Returns true if the event target is an input element where
|
|
12
|
-
* keyboard shortcuts should be suppressed.
|
|
13
|
-
*/
|
|
14
|
-
function isInputFocused(event: KeyboardEvent): boolean {
|
|
15
|
-
const target = event.target;
|
|
16
|
-
if (!(target instanceof HTMLElement)) return false;
|
|
17
|
-
|
|
18
|
-
const tagName = target.tagName;
|
|
19
|
-
if (tagName === "INPUT" || tagName === "TEXTAREA") return true;
|
|
20
|
-
if (target.isContentEditable) return true;
|
|
21
|
-
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Single centralized keyboard shortcut listener.
|
|
27
|
-
* Replaces all scattered useEffect keydown handlers.
|
|
28
|
-
*
|
|
29
|
-
* @param shortcuts - Resolved shortcut definitions (from useKeybindings)
|
|
30
|
-
* @param actions - Map of shortcut ID to callback function
|
|
31
|
-
*/
|
|
32
|
-
export function useKeyboardShortcuts(
|
|
33
|
-
shortcuts: ShortcutDefinition[],
|
|
34
|
-
actions: ActionMap,
|
|
35
|
-
): void {
|
|
36
|
-
const actionsRef = useRef(actions);
|
|
37
|
-
actionsRef.current = actions;
|
|
38
|
-
|
|
39
|
-
const shortcutsRef = useRef(shortcuts);
|
|
40
|
-
shortcutsRef.current = shortcuts;
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
44
|
-
if (isInputFocused(event)) return;
|
|
45
|
-
|
|
46
|
-
for (const shortcut of shortcutsRef.current) {
|
|
47
|
-
if (!shortcut.enabled) continue;
|
|
48
|
-
|
|
49
|
-
if (matchesBinding(event, shortcut.binding)) {
|
|
50
|
-
const action = actionsRef.current[shortcut.id];
|
|
51
|
-
if (action) {
|
|
52
|
-
event.preventDefault();
|
|
53
|
-
action();
|
|
54
|
-
}
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
61
|
-
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
62
|
-
}, []);
|
|
63
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { useCallback, useState } from "react";
|
|
2
|
-
import { type LayoutMode, LayoutModes } from "../types";
|
|
3
|
-
|
|
4
|
-
const STORAGE_KEY = "readit:layout-mode";
|
|
5
|
-
|
|
6
|
-
interface UseLayoutModeResult {
|
|
7
|
-
layoutMode: LayoutMode;
|
|
8
|
-
toggleLayoutMode: () => void;
|
|
9
|
-
isFullscreen: boolean;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function useLayoutMode(): UseLayoutModeResult {
|
|
13
|
-
const [layoutMode, setLayoutMode] = useState<LayoutMode>(() => {
|
|
14
|
-
try {
|
|
15
|
-
const stored = localStorage.getItem(STORAGE_KEY);
|
|
16
|
-
return stored === LayoutModes.FULLSCREEN
|
|
17
|
-
? LayoutModes.FULLSCREEN
|
|
18
|
-
: LayoutModes.CENTERED;
|
|
19
|
-
} catch {
|
|
20
|
-
return LayoutModes.CENTERED;
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
const toggleLayoutMode = useCallback(() => {
|
|
25
|
-
setLayoutMode((prev) => {
|
|
26
|
-
const next =
|
|
27
|
-
prev === LayoutModes.CENTERED
|
|
28
|
-
? LayoutModes.FULLSCREEN
|
|
29
|
-
: LayoutModes.CENTERED;
|
|
30
|
-
try {
|
|
31
|
-
localStorage.setItem(STORAGE_KEY, next);
|
|
32
|
-
} catch {
|
|
33
|
-
// localStorage may be unavailable
|
|
34
|
-
}
|
|
35
|
-
return next;
|
|
36
|
-
});
|
|
37
|
-
}, []);
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
layoutMode,
|
|
41
|
-
toggleLayoutMode,
|
|
42
|
-
isFullscreen: layoutMode === LayoutModes.FULLSCREEN,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { useCallback, useState } from "react";
|
|
2
|
-
import { type Locale, Locales } from "../lib/i18n";
|
|
3
|
-
|
|
4
|
-
const STORAGE_KEY = "readit:locale";
|
|
5
|
-
|
|
6
|
-
function detectLocale(): Locale {
|
|
7
|
-
const browserLang = navigator.language.slice(0, 2).toLowerCase();
|
|
8
|
-
if (browserLang === "ja") return Locales.JA;
|
|
9
|
-
return Locales.EN;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function getStoredLocale(): Locale {
|
|
13
|
-
try {
|
|
14
|
-
const stored = localStorage.getItem(STORAGE_KEY);
|
|
15
|
-
if (stored === Locales.JA || stored === Locales.EN) {
|
|
16
|
-
return stored;
|
|
17
|
-
}
|
|
18
|
-
} catch {
|
|
19
|
-
// localStorage may be unavailable
|
|
20
|
-
}
|
|
21
|
-
return detectLocale();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface UseLocalePreferenceResult {
|
|
25
|
-
locale: Locale;
|
|
26
|
-
setLocale: (locale: Locale) => void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function useLocalePreference(): UseLocalePreferenceResult {
|
|
30
|
-
const [locale, setLocaleState] = useState<Locale>(getStoredLocale);
|
|
31
|
-
|
|
32
|
-
const setLocale = useCallback((newLocale: Locale) => {
|
|
33
|
-
setLocaleState(newLocale);
|
|
34
|
-
try {
|
|
35
|
-
localStorage.setItem(STORAGE_KEY, newLocale);
|
|
36
|
-
} catch {
|
|
37
|
-
// localStorage may be unavailable
|
|
38
|
-
}
|
|
39
|
-
}, []);
|
|
40
|
-
|
|
41
|
-
return { locale, setLocale };
|
|
42
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
2
|
-
import { appStore, useAppStore } from "../store";
|
|
3
|
-
|
|
4
|
-
interface UseReanchorModeResult {
|
|
5
|
-
reanchorTarget: { commentId: string } | null;
|
|
6
|
-
startReanchor: (commentId: string) => void;
|
|
7
|
-
cancelReanchor: () => void;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Hook for managing re-anchor mode state.
|
|
12
|
-
* When active, the user can select new text to re-anchor an unresolved comment.
|
|
13
|
-
* State lives in the Zustand store for tab-switch preservation.
|
|
14
|
-
*/
|
|
15
|
-
export function useReanchorMode(): UseReanchorModeResult {
|
|
16
|
-
const reanchorTarget = useAppStore(
|
|
17
|
-
(s) => s.getActiveDocumentState()?.reanchorTarget ?? null,
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
const startReanchor = useCallback((commentId: string) => {
|
|
21
|
-
appStore.getState().setReanchorTarget({ commentId });
|
|
22
|
-
}, []);
|
|
23
|
-
|
|
24
|
-
const cancelReanchor = useCallback(() => {
|
|
25
|
-
appStore.getState().setReanchorTarget(null);
|
|
26
|
-
}, []);
|
|
27
|
-
|
|
28
|
-
return {
|
|
29
|
-
reanchorTarget,
|
|
30
|
-
startReanchor,
|
|
31
|
-
cancelReanchor,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
|
|
3
|
-
interface ScrollMetrics {
|
|
4
|
-
documentHeight: number;
|
|
5
|
-
viewportHeight: number;
|
|
6
|
-
scrollTop: number;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Track document scroll and viewport dimensions for minimap calculations.
|
|
11
|
-
* Updates are throttled to once per animation frame to prevent scroll jank.
|
|
12
|
-
*/
|
|
13
|
-
export function useScrollMetrics(): ScrollMetrics {
|
|
14
|
-
const [metrics, setMetrics] = useState<ScrollMetrics>({
|
|
15
|
-
documentHeight: 0,
|
|
16
|
-
viewportHeight: 0,
|
|
17
|
-
scrollTop: 0,
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const rafIdRef = useRef<number | null>(null);
|
|
21
|
-
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
const updateMetrics = () => {
|
|
24
|
-
setMetrics({
|
|
25
|
-
documentHeight: document.body.scrollHeight,
|
|
26
|
-
viewportHeight: window.innerHeight,
|
|
27
|
-
scrollTop: window.scrollY,
|
|
28
|
-
});
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
// Throttle scroll updates to once per animation frame
|
|
32
|
-
const handleScroll = () => {
|
|
33
|
-
if (rafIdRef.current !== null) return;
|
|
34
|
-
rafIdRef.current = requestAnimationFrame(() => {
|
|
35
|
-
updateMetrics();
|
|
36
|
-
rafIdRef.current = null;
|
|
37
|
-
});
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
// Initial measurement
|
|
41
|
-
updateMetrics();
|
|
42
|
-
|
|
43
|
-
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
44
|
-
window.addEventListener("resize", updateMetrics);
|
|
45
|
-
|
|
46
|
-
return () => {
|
|
47
|
-
window.removeEventListener("scroll", handleScroll);
|
|
48
|
-
window.removeEventListener("resize", updateMetrics);
|
|
49
|
-
if (rafIdRef.current !== null) {
|
|
50
|
-
cancelAnimationFrame(rafIdRef.current);
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
}, []);
|
|
54
|
-
|
|
55
|
-
return metrics;
|
|
56
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import { type ThemeMode, ThemeModes } from "../types";
|
|
3
|
-
|
|
4
|
-
const STORAGE_KEY = "readit:theme";
|
|
5
|
-
|
|
6
|
-
const DARK_MQ = "(prefers-color-scheme: dark)";
|
|
7
|
-
|
|
8
|
-
function getStoredTheme(): ThemeMode {
|
|
9
|
-
try {
|
|
10
|
-
const stored = localStorage.getItem(STORAGE_KEY);
|
|
11
|
-
if (
|
|
12
|
-
stored === ThemeModes.LIGHT ||
|
|
13
|
-
stored === ThemeModes.DARK ||
|
|
14
|
-
stored === ThemeModes.SYSTEM
|
|
15
|
-
) {
|
|
16
|
-
return stored;
|
|
17
|
-
}
|
|
18
|
-
} catch {
|
|
19
|
-
// localStorage may be unavailable
|
|
20
|
-
}
|
|
21
|
-
return ThemeModes.SYSTEM;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function applyTheme(mode: ThemeMode): void {
|
|
25
|
-
const isDark =
|
|
26
|
-
mode === ThemeModes.DARK ||
|
|
27
|
-
(mode === ThemeModes.SYSTEM && window.matchMedia(DARK_MQ).matches);
|
|
28
|
-
|
|
29
|
-
document.documentElement.classList.toggle("dark", isDark);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface UseThemePreferenceResult {
|
|
33
|
-
themeMode: ThemeMode;
|
|
34
|
-
setThemeMode: (mode: ThemeMode) => void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function useThemePreference(): UseThemePreferenceResult {
|
|
38
|
-
const [themeMode, setThemeModeState] = useState<ThemeMode>(getStoredTheme);
|
|
39
|
-
|
|
40
|
-
// Apply theme class whenever mode changes
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
applyTheme(themeMode);
|
|
43
|
-
}, [themeMode]);
|
|
44
|
-
|
|
45
|
-
// Listen for system preference changes when in "system" mode
|
|
46
|
-
useEffect(() => {
|
|
47
|
-
if (themeMode !== ThemeModes.SYSTEM) return;
|
|
48
|
-
|
|
49
|
-
const mq = window.matchMedia(DARK_MQ);
|
|
50
|
-
const handler = () => applyTheme(ThemeModes.SYSTEM);
|
|
51
|
-
|
|
52
|
-
mq.addEventListener("change", handler);
|
|
53
|
-
return () => mq.removeEventListener("change", handler);
|
|
54
|
-
}, [themeMode]);
|
|
55
|
-
|
|
56
|
-
const setThemeMode = useCallback((mode: ThemeMode) => {
|
|
57
|
-
setThemeModeState(mode);
|
|
58
|
-
try {
|
|
59
|
-
localStorage.setItem(STORAGE_KEY, mode);
|
|
60
|
-
} catch {
|
|
61
|
-
// localStorage may be unavailable
|
|
62
|
-
}
|
|
63
|
-
}, []);
|
|
64
|
-
|
|
65
|
-
return { themeMode, setThemeMode };
|
|
66
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { bench, describe } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
COMMENT_FILE_LARGE,
|
|
4
|
-
COMMENT_FILE_MEDIUM,
|
|
5
|
-
COMMENT_FILE_OBJ_LARGE,
|
|
6
|
-
COMMENT_FILE_OBJ_MEDIUM,
|
|
7
|
-
COMMENT_FILE_SMALL,
|
|
8
|
-
LARGE_DOC,
|
|
9
|
-
} from "./__fixtures__/bench-data";
|
|
10
|
-
import {
|
|
11
|
-
computeHash,
|
|
12
|
-
createComment,
|
|
13
|
-
parseCommentFile,
|
|
14
|
-
serializeComments,
|
|
15
|
-
} from "./comment-storage";
|
|
16
|
-
|
|
17
|
-
describe("parseCommentFile", () => {
|
|
18
|
-
bench("1 comment", () => {
|
|
19
|
-
parseCommentFile(COMMENT_FILE_SMALL);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
bench("10 comments", () => {
|
|
23
|
-
parseCommentFile(COMMENT_FILE_MEDIUM);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
bench("50 comments", () => {
|
|
27
|
-
parseCommentFile(COMMENT_FILE_LARGE);
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("serializeComments", () => {
|
|
32
|
-
bench("10 comments", () => {
|
|
33
|
-
serializeComments(COMMENT_FILE_OBJ_MEDIUM);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
bench("50 comments", () => {
|
|
37
|
-
serializeComments(COMMENT_FILE_OBJ_LARGE);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("computeHash", () => {
|
|
42
|
-
const shortString = "x".repeat(100);
|
|
43
|
-
|
|
44
|
-
bench("short string (100 chars)", () => {
|
|
45
|
-
computeHash(shortString);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
bench("large doc (~10k chars)", () => {
|
|
49
|
-
computeHash(LARGE_DOC);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe("createComment", () => {
|
|
54
|
-
bench("short selection", () => {
|
|
55
|
-
createComment("selected text here", "my comment", 100, 118, LARGE_DOC);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const longSelection = "a".repeat(2000);
|
|
59
|
-
|
|
60
|
-
bench("long selection (triggers truncation)", () => {
|
|
61
|
-
createComment(longSelection, "my comment", 0, 2000, LARGE_DOC);
|
|
62
|
-
});
|
|
63
|
-
});
|
package/src/lib/context.bench.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { bench, describe } from "vitest";
|
|
2
|
-
import { HTML_DOC, LARGE_DOC } from "./__fixtures__/bench-data";
|
|
3
|
-
import { extractContext, formatForLLM, stripHtmlTags } from "./context";
|
|
4
|
-
|
|
5
|
-
describe("stripHtmlTags", () => {
|
|
6
|
-
bench("200-line HTML document", () => {
|
|
7
|
-
stripHtmlTags(HTML_DOC);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
bench("short HTML string", () => {
|
|
11
|
-
stripHtmlTags(
|
|
12
|
-
"<p>Hello & <strong>world</strong> with <entities></p>",
|
|
13
|
-
);
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
describe("extractContext", () => {
|
|
18
|
-
bench("single-line selection, markdown", () => {
|
|
19
|
-
extractContext({ content: LARGE_DOC, startOffset: 500, endOffset: 530 });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
bench("multi-line selection, markdown", () => {
|
|
23
|
-
extractContext({ content: LARGE_DOC, startOffset: 500, endOffset: 800 });
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
bench("HTML content (triggers stripHtmlTags)", () => {
|
|
27
|
-
extractContext({ content: HTML_DOC, startOffset: 100, endOffset: 200 });
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("formatForLLM", () => {
|
|
32
|
-
const ctx = extractContext({
|
|
33
|
-
content: LARGE_DOC,
|
|
34
|
-
startOffset: 500,
|
|
35
|
-
endOffset: 530,
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
bench("format with comment", () => {
|
|
39
|
-
formatForLLM({ context: ctx, fileName: "doc.md", comment: "Needs review" });
|
|
40
|
-
});
|
|
41
|
-
});
|
package/src/lib/context.test.ts
DELETED
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { extractContext, formatForLLM, stripHtmlTags } from "./context";
|
|
3
|
-
|
|
4
|
-
describe("stripHtmlTags", () => {
|
|
5
|
-
it("removes simple HTML tags", () => {
|
|
6
|
-
expect(stripHtmlTags("<p>Hello</p>")).toBe("Hello");
|
|
7
|
-
expect(stripHtmlTags("<div><span>Nested</span></div>")).toBe("Nested");
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it("removes script and style content entirely", () => {
|
|
11
|
-
expect(stripHtmlTags('<script>alert("xss")</script>text')).toBe("text");
|
|
12
|
-
expect(stripHtmlTags("<style>.foo { color: red }</style>text")).toBe(
|
|
13
|
-
"text",
|
|
14
|
-
);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("decodes common named entities", () => {
|
|
18
|
-
expect(stripHtmlTags("<tag>")).toBe("<tag>");
|
|
19
|
-
expect(stripHtmlTags("& "'")).toBe("& \"'");
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("decodes numeric entities (decimal)", () => {
|
|
23
|
-
expect(stripHtmlTags("ABC")).toBe("ABC");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("decodes numeric entities (hex)", () => {
|
|
27
|
-
expect(stripHtmlTags("ABC")).toBe("ABC");
|
|
28
|
-
expect(stripHtmlTags("ABC")).toBe("ABC"); // case-insensitive
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("handles mixed content", () => {
|
|
32
|
-
const html = "<p>Hello & <strong>World</strong>!</p>";
|
|
33
|
-
expect(stripHtmlTags(html)).toBe("Hello & World!");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("preserves plain text", () => {
|
|
37
|
-
expect(stripHtmlTags("plain text")).toBe("plain text");
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("extractContext", () => {
|
|
42
|
-
it("extracts single-line selection with markers", () => {
|
|
43
|
-
const content = "line1\nline2\nline3\nline4\nline5";
|
|
44
|
-
// "line2" starts at offset 6, ends at 11
|
|
45
|
-
const result = extractContext({ content, startOffset: 6, endOffset: 11 });
|
|
46
|
-
|
|
47
|
-
expect(result.startLine).toBe(2);
|
|
48
|
-
expect(result.endLine).toBe(2);
|
|
49
|
-
// Should have >>> and <<< markers around "line2"
|
|
50
|
-
expect(result.lines.some((l) => l.includes(">>> line2 <<<"))).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("includes context lines before and after", () => {
|
|
54
|
-
const content = "line1\nline2\nline3\nline4\nline5";
|
|
55
|
-
// Select "line3" at offset 12
|
|
56
|
-
const result = extractContext({
|
|
57
|
-
content,
|
|
58
|
-
startOffset: 12,
|
|
59
|
-
endOffset: 17,
|
|
60
|
-
contextLines: 2,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
expect(result.startLine).toBe(3);
|
|
64
|
-
expect(result.endLine).toBe(3);
|
|
65
|
-
// Should include 2 lines before (line1, line2) and 2 after (line4, line5)
|
|
66
|
-
expect(result.lines.length).toBe(5);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("handles multi-line selection", () => {
|
|
70
|
-
const content = "line1\nline2\nline3\nline4\nline5";
|
|
71
|
-
// Select from "line2" to "line3" (offset 6 to 17)
|
|
72
|
-
const result = extractContext({ content, startOffset: 6, endOffset: 17 });
|
|
73
|
-
|
|
74
|
-
expect(result.startLine).toBe(2);
|
|
75
|
-
expect(result.endLine).toBe(3);
|
|
76
|
-
// Start line should have >>> marker
|
|
77
|
-
expect(result.lines.some((l) => l.includes(">>>"))).toBe(true);
|
|
78
|
-
// End line should have <<< marker
|
|
79
|
-
expect(result.lines.some((l) => l.includes("<<<"))).toBe(true);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("handles selection at start of document", () => {
|
|
83
|
-
const content = "line1\nline2\nline3";
|
|
84
|
-
// Select "line1" at start
|
|
85
|
-
const result = extractContext({ content, startOffset: 0, endOffset: 5 });
|
|
86
|
-
|
|
87
|
-
expect(result.startLine).toBe(1);
|
|
88
|
-
expect(result.endLine).toBe(1);
|
|
89
|
-
expect(result.lines[0]).toContain(">>> line1 <<<");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("handles selection at end of document", () => {
|
|
93
|
-
const content = "line1\nline2\nline3";
|
|
94
|
-
// Select "line3" (offset 12 to 17)
|
|
95
|
-
const result = extractContext({ content, startOffset: 12, endOffset: 17 });
|
|
96
|
-
|
|
97
|
-
expect(result.startLine).toBe(3);
|
|
98
|
-
expect(result.endLine).toBe(3);
|
|
99
|
-
expect(result.lines.some((l) => l.includes(">>> line3 <<<"))).toBe(true);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("truncates very long lines", () => {
|
|
103
|
-
const longLine = "a".repeat(250);
|
|
104
|
-
const content = `short\n${longLine}\nshort`;
|
|
105
|
-
// Select part of the long line
|
|
106
|
-
const result = extractContext({ content, startOffset: 6, endOffset: 256 });
|
|
107
|
-
|
|
108
|
-
// Long line should be truncated with ...
|
|
109
|
-
const longLineOutput = result.lines.find((l) => l.length > 100);
|
|
110
|
-
expect(longLineOutput?.endsWith("...")).toBe(true);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("handles HTML content by stripping tags", () => {
|
|
114
|
-
const html = "<p>paragraph</p>\n<div>div content</div>";
|
|
115
|
-
// After stripping: "paragraph\ndiv content"
|
|
116
|
-
// Select "paragraph" at offset 0-9
|
|
117
|
-
const result = extractContext({
|
|
118
|
-
content: html,
|
|
119
|
-
startOffset: 0,
|
|
120
|
-
endOffset: 9,
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
expect(result.lines.some((l) => l.includes(">>> paragraph <<<"))).toBe(
|
|
124
|
-
true,
|
|
125
|
-
);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("normalizes CRLF to LF", () => {
|
|
129
|
-
const content = "line1\r\nline2\r\nline3";
|
|
130
|
-
// After normalization: "line1\nline2\nline3"
|
|
131
|
-
// Select "line2" at offset 6
|
|
132
|
-
const result = extractContext({ content, startOffset: 6, endOffset: 11 });
|
|
133
|
-
|
|
134
|
-
expect(result.startLine).toBe(2);
|
|
135
|
-
expect(result.lines.some((l) => l.includes(">>> line2 <<<"))).toBe(true);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("limits context lines to document bounds", () => {
|
|
139
|
-
const content = "only\ntwo\nlines";
|
|
140
|
-
// Select middle line with 5 context lines requested
|
|
141
|
-
const result = extractContext({
|
|
142
|
-
content,
|
|
143
|
-
startOffset: 5,
|
|
144
|
-
endOffset: 8,
|
|
145
|
-
contextLines: 5,
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// Should not go beyond document bounds
|
|
149
|
-
expect(result.lines.length).toBeLessThanOrEqual(3);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("truncates very long selections with ellipsis", () => {
|
|
153
|
-
// Create content with more than MAX_SELECTION_LINES (10) lines
|
|
154
|
-
const lines = Array.from({ length: 15 }, (_, i) => `line${i + 1}`);
|
|
155
|
-
const content = lines.join("\n");
|
|
156
|
-
// Select all content
|
|
157
|
-
const result = extractContext({
|
|
158
|
-
content,
|
|
159
|
-
startOffset: 0,
|
|
160
|
-
endOffset: content.length,
|
|
161
|
-
contextLines: 0,
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// Should include ... for truncated middle
|
|
165
|
-
expect(result.lines.some((l) => l === "...")).toBe(true);
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
describe("formatForLLM", () => {
|
|
170
|
-
it("formats context with header and line range", () => {
|
|
171
|
-
const context = {
|
|
172
|
-
lines: ["before", ">>> selected <<<", "after"],
|
|
173
|
-
startLine: 5,
|
|
174
|
-
endLine: 5,
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
const result = formatForLLM({ context, fileName: "test.md" });
|
|
178
|
-
|
|
179
|
-
expect(result).toContain("# From: test.md");
|
|
180
|
-
expect(result).toContain("Lines 5-5:");
|
|
181
|
-
expect(result).toContain("---");
|
|
182
|
-
expect(result).toContain(">>> selected <<<");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("includes optional comment", () => {
|
|
186
|
-
const context = {
|
|
187
|
-
lines: ["text"],
|
|
188
|
-
startLine: 1,
|
|
189
|
-
endLine: 1,
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
const result = formatForLLM({
|
|
193
|
-
context,
|
|
194
|
-
fileName: "test.md",
|
|
195
|
-
comment: "This needs review",
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
expect(result).toContain("Comment: This needs review");
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("omits comment section when not provided", () => {
|
|
202
|
-
const context = {
|
|
203
|
-
lines: ["text"],
|
|
204
|
-
startLine: 1,
|
|
205
|
-
endLine: 1,
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
const result = formatForLLM({ context, fileName: "test.md" });
|
|
209
|
-
|
|
210
|
-
expect(result).not.toContain("Comment:");
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("formats multi-line range correctly", () => {
|
|
214
|
-
const context = {
|
|
215
|
-
lines: [">>> start", "middle", "end <<<"],
|
|
216
|
-
startLine: 10,
|
|
217
|
-
endLine: 12,
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const result = formatForLLM({ context, fileName: "doc.html" });
|
|
221
|
-
|
|
222
|
-
expect(result).toContain("Lines 10-12:");
|
|
223
|
-
});
|
|
224
|
-
});
|