@peaske7/readit 0.1.8 → 0.2.1

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 (221) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -5
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -710
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +130 -0
  12. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  13. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  14. package/e2e/comments.spec.ts +14 -58
  15. package/e2e/document-load.spec.ts +1 -23
  16. package/e2e/export.spec.ts +4 -4
  17. package/e2e/perf/add-comment.spec.ts +116 -0
  18. package/e2e/perf/fixtures/generate.ts +327 -0
  19. package/e2e/perf/initial-load.spec.ts +49 -0
  20. package/e2e/perf/perf.setup.ts +23 -0
  21. package/e2e/perf/perf.teardown.ts +9 -0
  22. package/e2e/perf/screenshot-final.png +0 -0
  23. package/e2e/perf/scroll.spec.ts +39 -0
  24. package/e2e/perf/tab-switch.spec.ts +69 -0
  25. package/e2e/perf/text-selection.spec.ts +119 -0
  26. package/e2e/perf/utils/metrics.ts +350 -0
  27. package/e2e/perf/utils/perf-cli.ts +86 -0
  28. package/e2e/persistence-file.spec.ts +41 -26
  29. package/e2e/utils/selection.ts +17 -73
  30. package/go/cmd/readit/main.go +416 -0
  31. package/go/go.mod +20 -0
  32. package/go/go.sum +41 -0
  33. package/go/internal/server/anchor.go +302 -0
  34. package/go/internal/server/anchor_test.go +111 -0
  35. package/go/internal/server/comments.go +390 -0
  36. package/go/internal/server/documents.go +113 -0
  37. package/go/internal/server/embed.go +17 -0
  38. package/go/internal/server/headings.go +33 -0
  39. package/go/internal/server/headings_test.go +75 -0
  40. package/go/internal/server/htmltext.go +123 -0
  41. package/go/internal/server/markdown.go +157 -0
  42. package/go/internal/server/markdown_bench_test.go +42 -0
  43. package/go/internal/server/markdown_test.go +79 -0
  44. package/go/internal/server/server.go +453 -0
  45. package/go/internal/server/server_bench_test.go +122 -0
  46. package/go/internal/server/settings.go +110 -0
  47. package/go/internal/server/sse.go +140 -0
  48. package/go/internal/server/storage.go +275 -0
  49. package/go/internal/server/storage_test.go +118 -0
  50. package/go/internal/server/template.go +66 -0
  51. package/go/internal/server/types.go +101 -0
  52. package/go/internal/server/watcher.go +74 -0
  53. package/index.html +4 -14
  54. package/nvim-readit/lua/readit/health.lua +64 -0
  55. package/nvim-readit/lua/readit/init.lua +463 -0
  56. package/nvim-readit/plugin/readit.lua +19 -0
  57. package/package.json +24 -41
  58. package/playwright.config.ts +12 -0
  59. package/shell/_readit +158 -0
  60. package/shell/readit.zsh +87 -0
  61. package/src/App.svelte +881 -0
  62. package/src/{cli/index.ts → cli.ts} +216 -70
  63. package/src/components/ActionsMenu.svelte +95 -0
  64. package/src/components/CommentBadge.svelte +67 -0
  65. package/src/components/CommentErrorBanner.svelte +33 -0
  66. package/src/components/CommentInput.svelte +75 -0
  67. package/src/components/CommentListItem.svelte +95 -0
  68. package/src/components/CommentManager.svelte +129 -0
  69. package/src/components/CommentNav.svelte +109 -0
  70. package/src/components/DocumentViewer.svelte +218 -0
  71. package/src/components/FloatingComment.svelte +107 -0
  72. package/src/components/Header.svelte +76 -0
  73. package/src/components/InlineEditor.svelte +72 -0
  74. package/src/components/MarginNote.svelte +167 -0
  75. package/src/components/MarginNotesContainer.svelte +33 -0
  76. package/src/components/RawModal.svelte +126 -0
  77. package/src/components/ReanchorConfirm.svelte +30 -0
  78. package/src/components/SettingsModal.svelte +220 -0
  79. package/src/components/ShortcutCapture.svelte +82 -0
  80. package/src/components/ShortcutList.svelte +145 -0
  81. package/src/components/TabBar.svelte +52 -0
  82. package/src/components/TableOfContents.svelte +125 -0
  83. package/src/components/ui/ActionLink.svelte +40 -0
  84. package/src/components/ui/Button.svelte +53 -0
  85. package/src/components/ui/Dialog.svelte +97 -0
  86. package/src/components/ui/DropdownMenu.svelte +85 -0
  87. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  88. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  89. package/src/components/ui/Text.svelte +42 -0
  90. package/src/env.d.ts +6 -0
  91. package/src/index.css +36 -166
  92. package/src/lib/__fixtures__/bench-data.ts +1 -54
  93. package/src/lib/anchor.bench.ts +47 -68
  94. package/src/lib/anchor.test.ts +5 -9
  95. package/src/lib/anchor.ts +9 -93
  96. package/src/lib/comment-storage.bench.ts +6 -20
  97. package/src/lib/comment-storage.test.ts +45 -37
  98. package/src/lib/comment-storage.ts +23 -64
  99. package/src/lib/export.bench.ts +9 -23
  100. package/src/lib/export.ts +7 -14
  101. package/src/lib/headings.test.ts +103 -0
  102. package/src/lib/headings.ts +44 -0
  103. package/src/lib/highlight/core.test.ts +1 -6
  104. package/src/lib/highlight/dom.ts +53 -280
  105. package/src/lib/highlight/highlight-registry.ts +221 -0
  106. package/src/lib/highlight/highlight.bench.ts +92 -0
  107. package/src/lib/highlight/highlighter.ts +122 -302
  108. package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
  109. package/src/lib/highlight/types.ts +0 -40
  110. package/src/lib/html-text.test.ts +162 -0
  111. package/src/lib/html-text.ts +161 -0
  112. package/src/lib/i18n/en.ts +13 -36
  113. package/src/lib/i18n/ja.ts +14 -37
  114. package/src/lib/i18n/types.ts +13 -36
  115. package/src/lib/margin-layout.bench.ts +48 -15
  116. package/src/lib/margin-layout.ts +2 -31
  117. package/src/lib/markdown-renderer.test.ts +154 -0
  118. package/src/lib/markdown-renderer.ts +177 -0
  119. package/src/lib/mermaid-config.ts +38 -0
  120. package/src/lib/mermaid-renderer.ts +162 -0
  121. package/src/lib/mermaid-worker.ts +60 -0
  122. package/src/lib/positions.ts +157 -0
  123. package/src/lib/shortcut-registry.ts +138 -103
  124. package/src/lib/utils.ts +2 -48
  125. package/src/main.ts +16 -0
  126. package/src/schema.ts +92 -0
  127. package/src/{server/index.ts → server.ts} +427 -163
  128. package/src/stores/app.svelte.ts +231 -0
  129. package/src/stores/locale.svelte.ts +46 -0
  130. package/src/stores/settings.svelte.ts +90 -0
  131. package/src/stores/shortcuts.svelte.ts +104 -0
  132. package/src/stores/ui.svelte.ts +12 -0
  133. package/src/template.ts +104 -0
  134. package/src/test-setup.ts +47 -0
  135. package/svelte.config.js +5 -0
  136. package/tsconfig.json +2 -2
  137. package/vite.config.ts +31 -3
  138. package/vscode-readit/.mcp.json +7 -0
  139. package/vscode-readit/.vscodeignore +7 -0
  140. package/vscode-readit/bun.lock +78 -0
  141. package/vscode-readit/icon.svg +10 -0
  142. package/vscode-readit/package.json +110 -0
  143. package/vscode-readit/src/extension.ts +117 -0
  144. package/vscode-readit/src/server-manager.ts +272 -0
  145. package/vscode-readit/src/webview-provider.ts +204 -0
  146. package/vscode-readit/tsconfig.json +20 -0
  147. package/e2e/fixtures/sample.html +0 -13
  148. package/src/App.tsx +0 -416
  149. package/src/components/ActionsMenu.tsx +0 -112
  150. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  151. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
  152. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  153. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  154. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
  155. package/src/components/DocumentViewer/index.ts +0 -1
  156. package/src/components/FloatingTOC.tsx +0 -61
  157. package/src/components/Header.tsx +0 -65
  158. package/src/components/InlineEditor.tsx +0 -74
  159. package/src/components/MarginNote.tsx +0 -207
  160. package/src/components/MarginNotes.tsx +0 -50
  161. package/src/components/RawModal.tsx +0 -143
  162. package/src/components/ReanchorConfirm.tsx +0 -36
  163. package/src/components/SettingsModal.tsx +0 -310
  164. package/src/components/ShortcutCapture.tsx +0 -48
  165. package/src/components/ShortcutList.tsx +0 -198
  166. package/src/components/TabBar.tsx +0 -60
  167. package/src/components/TableOfContents.tsx +0 -108
  168. package/src/components/comments/CommentBadge.tsx +0 -49
  169. package/src/components/comments/CommentInput.tsx +0 -114
  170. package/src/components/comments/CommentListItem.tsx +0 -92
  171. package/src/components/comments/CommentManager.tsx +0 -113
  172. package/src/components/comments/CommentMinimap.tsx +0 -62
  173. package/src/components/comments/CommentNav.tsx +0 -109
  174. package/src/components/ui/ActionBar.tsx +0 -16
  175. package/src/components/ui/ActionLink.tsx +0 -32
  176. package/src/components/ui/Button.tsx +0 -55
  177. package/src/components/ui/Dialog.tsx +0 -156
  178. package/src/components/ui/DropdownMenu.tsx +0 -114
  179. package/src/components/ui/SeparatorDot.tsx +0 -9
  180. package/src/components/ui/Text.tsx +0 -54
  181. package/src/contexts/CommentContext.tsx +0 -229
  182. package/src/contexts/LayoutContext.tsx +0 -88
  183. package/src/contexts/LocaleContext.tsx +0 -35
  184. package/src/hooks/useClickOutside.ts +0 -35
  185. package/src/hooks/useClipboard.ts +0 -82
  186. package/src/hooks/useCommentNavigation.ts +0 -130
  187. package/src/hooks/useComments.ts +0 -323
  188. package/src/hooks/useDocument.ts +0 -156
  189. package/src/hooks/useEditorScheme.ts +0 -51
  190. package/src/hooks/useFontPreference.ts +0 -59
  191. package/src/hooks/useHeadings.test.ts +0 -159
  192. package/src/hooks/useHeadings.ts +0 -129
  193. package/src/hooks/useKeybindings.ts +0 -108
  194. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  195. package/src/hooks/useLayoutMode.ts +0 -44
  196. package/src/hooks/useLocalePreference.ts +0 -42
  197. package/src/hooks/useReanchorMode.ts +0 -33
  198. package/src/hooks/useScrollMetrics.ts +0 -56
  199. package/src/hooks/useScrollSpy.ts +0 -81
  200. package/src/hooks/useTextSelection.ts +0 -123
  201. package/src/hooks/useThemePreference.ts +0 -66
  202. package/src/lib/context.bench.ts +0 -41
  203. package/src/lib/context.test.ts +0 -224
  204. package/src/lib/context.ts +0 -193
  205. package/src/lib/editor-links.ts +0 -59
  206. package/src/lib/highlight/colors.ts +0 -37
  207. package/src/lib/highlight/index.ts +0 -23
  208. package/src/lib/highlight/script-builder.ts +0 -485
  209. package/src/lib/html-processor.test.tsx +0 -170
  210. package/src/lib/html-processor.tsx +0 -95
  211. package/src/lib/i18n/completeness.test.ts +0 -51
  212. package/src/lib/i18n/translations.test.ts +0 -39
  213. package/src/lib/layout-constants.ts +0 -12
  214. package/src/lib/scroll.test.ts +0 -118
  215. package/src/lib/scroll.ts +0 -47
  216. package/src/lib/shortcut-registry.test.ts +0 -173
  217. package/src/lib/utils.test.ts +0 -110
  218. package/src/main.tsx +0 -13
  219. package/src/store/index.test.ts +0 -242
  220. package/src/store/index.ts +0 -254
  221. package/src/types/index.ts +0 -127
@@ -1,156 +0,0 @@
1
- import { useCallback, useEffect, useState } from "react";
2
- import { toast } from "sonner";
3
- import { appStore, useAppStore } from "../store";
4
- import type { Document } from "../types";
5
-
6
- interface UseDocumentResult {
7
- document: Document | null;
8
- error: string | null;
9
- isInitialized: boolean;
10
- reload: () => Promise<void>;
11
- }
12
-
13
- interface DocListItem {
14
- path: string;
15
- fileName: string;
16
- type: Document["type"];
17
- }
18
-
19
- /**
20
- * Manage multi-document loading, lazy content fetching, and live reloading.
21
- *
22
- * On mount: fetches the document list from `/api/documents` and opens all
23
- * files in the store. Content is loaded lazily when a tab becomes active.
24
- * SSE events trigger content updates for already-loaded documents.
25
- */
26
- export function useDocument(): UseDocumentResult {
27
- const [error, setError] = useState<string | null>(null);
28
- const [isInitialized, setIsInitialized] = useState(false);
29
-
30
- const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
31
-
32
- // Active document — null until content is loaded
33
- const document = useAppStore((s) => {
34
- const ds = s.getActiveDocumentState();
35
- if (!ds || !ds.document.content) return null;
36
- return ds.document;
37
- });
38
-
39
- // Fetch document list on mount, populate store
40
- useEffect(() => {
41
- async function init() {
42
- try {
43
- const res = await fetch("/api/documents");
44
- if (!res.ok) throw new Error(`Server error: ${res.status}`);
45
- const data = await res.json();
46
-
47
- const clean = data.clean || false;
48
- if (data.workingDirectory) {
49
- appStore.getState().setWorkingDirectory(data.workingDirectory);
50
- }
51
- data.files.forEach((file: DocListItem, index: number) => {
52
- appStore.getState().openDocument(
53
- {
54
- content: "", // Content loaded lazily on tab activation
55
- type: file.type,
56
- filePath: file.path,
57
- fileName: file.fileName,
58
- clean,
59
- },
60
- { active: index === 0 },
61
- );
62
- });
63
- } catch (err) {
64
- setError(
65
- err instanceof Error ? err.message : "Failed to load documents",
66
- );
67
- } finally {
68
- setIsInitialized(true);
69
- }
70
- }
71
- init();
72
- }, []);
73
-
74
- // Load content when active document changes and has no content yet
75
- useEffect(() => {
76
- if (!activeDocumentPath) return;
77
- const state = appStore.getState().documents.get(activeDocumentPath);
78
- if (!state || state.document.content) return;
79
-
80
- async function loadContent() {
81
- try {
82
- const res = await fetch(
83
- `/api/document?path=${encodeURIComponent(activeDocumentPath!)}`,
84
- );
85
- if (!res.ok) throw new Error(`Server error: ${res.status}`);
86
- const data = await res.json();
87
- appStore
88
- .getState()
89
- .updateDocumentContent(data.content, activeDocumentPath!);
90
- } catch (err) {
91
- setError(
92
- err instanceof Error ? err.message : "Failed to load document",
93
- );
94
- }
95
- }
96
- loadContent();
97
- }, [activeDocumentPath]);
98
-
99
- // SSE: register new documents without stealing focus; reload loaded docs on updates
100
- useEffect(() => {
101
- const eventSource = new EventSource("/api/document/stream");
102
- eventSource.onmessage = async (e) => {
103
- try {
104
- const data = JSON.parse(e.data);
105
- if (data.type === "document-added" && data.path) {
106
- appStore.getState().openDocument(
107
- {
108
- content: "", // Lazy-loaded when tab activated
109
- type: data.fileType,
110
- filePath: data.path,
111
- fileName: data.fileName,
112
- clean: false,
113
- },
114
- { active: false },
115
- );
116
- return;
117
- }
118
- if (data.type === "document-updated" && data.path) {
119
- // Only reload if content was previously loaded
120
- const state = appStore.getState().documents.get(data.path);
121
- if (!state || !state.document.content) return;
122
-
123
- const res = await fetch(
124
- `/api/document?path=${encodeURIComponent(data.path)}`,
125
- );
126
- if (res.ok) {
127
- const doc = await res.json();
128
- appStore.getState().updateDocumentContent(doc.content, data.path);
129
- }
130
- }
131
- } catch {
132
- // Ignore non-JSON messages ("connected", "ping")
133
- }
134
- };
135
- return () => eventSource.close();
136
- }, []);
137
-
138
- const reload = useCallback(async () => {
139
- if (!activeDocumentPath) return;
140
- try {
141
- const res = await fetch(
142
- `/api/document?path=${encodeURIComponent(activeDocumentPath)}`,
143
- );
144
- if (!res.ok) throw new Error(`Server error: ${res.status}`);
145
- const data = await res.json();
146
- appStore
147
- .getState()
148
- .updateDocumentContent(data.content, activeDocumentPath);
149
- toast.success("Document reloaded");
150
- } catch (err) {
151
- toast.error(err instanceof Error ? err.message : "Failed to reload");
152
- }
153
- }, [activeDocumentPath]);
154
-
155
- return { document, error, isInitialized, reload };
156
- }
@@ -1,51 +0,0 @@
1
- import { useCallback, useEffect, useState } from "react";
2
- import { toast } from "sonner";
3
- import { type EditorScheme, EditorSchemes } from "../types";
4
-
5
- interface UseEditorSchemeResult {
6
- editorScheme: EditorScheme;
7
- setEditorScheme: (scheme: EditorScheme) => Promise<void>;
8
- }
9
-
10
- export function useEditorScheme(): UseEditorSchemeResult {
11
- const [editorScheme, setEditorSchemeState] = useState<EditorScheme>(
12
- EditorSchemes.NONE,
13
- );
14
-
15
- useEffect(() => {
16
- const fetchSettings = async () => {
17
- try {
18
- const response = await fetch("/api/settings");
19
- if (response.ok) {
20
- const settings = await response.json();
21
- setEditorSchemeState(settings.editorScheme || EditorSchemes.NONE);
22
- }
23
- } catch (err) {
24
- console.error("Failed to fetch settings:", err);
25
- }
26
- };
27
-
28
- fetchSettings();
29
- }, []);
30
-
31
- const setEditorScheme = useCallback(async (scheme: EditorScheme) => {
32
- setEditorSchemeState(scheme);
33
-
34
- try {
35
- const response = await fetch("/api/settings", {
36
- method: "PUT",
37
- headers: { "Content-Type": "application/json" },
38
- body: JSON.stringify({ editorScheme: scheme }),
39
- });
40
-
41
- if (!response.ok) {
42
- throw new Error("Failed to save settings");
43
- }
44
- } catch (err) {
45
- console.error("Failed to save editor scheme:", err);
46
- toast.error("Failed to save editor scheme");
47
- }
48
- }, []);
49
-
50
- return { editorScheme, setEditorScheme };
51
- }
@@ -1,59 +0,0 @@
1
- import { useCallback, useEffect, useState } from "react";
2
- import { toast } from "sonner";
3
- import { FontFamilies, type FontFamily } from "../types";
4
-
5
- interface UseFontPreferenceResult {
6
- fontFamily: FontFamily;
7
- setFontFamily: (font: FontFamily) => Promise<void>;
8
- isLoading: boolean;
9
- }
10
-
11
- export function useFontPreference(): UseFontPreferenceResult {
12
- const [fontFamily, setFontFamilyState] = useState<FontFamily>(
13
- FontFamilies.SERIF,
14
- );
15
- const [isLoading, setIsLoading] = useState(true);
16
-
17
- useEffect(() => {
18
- const fetchSettings = async () => {
19
- try {
20
- const response = await fetch("/api/settings");
21
- if (response.ok) {
22
- const settings = await response.json();
23
- setFontFamilyState(settings.fontFamily || FontFamilies.SERIF);
24
- }
25
- } catch (err) {
26
- console.error("Failed to fetch settings:", err);
27
- } finally {
28
- setIsLoading(false);
29
- }
30
- };
31
-
32
- fetchSettings();
33
- }, []);
34
-
35
- const setFontFamily = useCallback(async (font: FontFamily) => {
36
- setFontFamilyState(font);
37
-
38
- try {
39
- const response = await fetch("/api/settings", {
40
- method: "PUT",
41
- headers: { "Content-Type": "application/json" },
42
- body: JSON.stringify({ fontFamily: font }),
43
- });
44
-
45
- if (!response.ok) {
46
- throw new Error("Failed to save settings");
47
- }
48
- } catch (err) {
49
- console.error("Failed to save font preference:", err);
50
- toast.error("Failed to save font preference");
51
- }
52
- }, []);
53
-
54
- return {
55
- fontFamily,
56
- setFontFamily,
57
- isLoading,
58
- };
59
- }
@@ -1,159 +0,0 @@
1
- import { renderHook } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
3
- import { useHeadings } from "./useHeadings";
4
-
5
- describe("useHeadings - markdown", () => {
6
- it("extracts basic headings", () => {
7
- const content = `# Heading 1
8
- ## Heading 2
9
- ### Heading 3`;
10
-
11
- const { result } = renderHook(() => useHeadings(content, "markdown"));
12
-
13
- expect(result.current).toEqual([
14
- { id: "heading-1", text: "Heading 1", level: 1 },
15
- { id: "heading-2", text: "Heading 2", level: 2 },
16
- { id: "heading-3", text: "Heading 3", level: 3 },
17
- ]);
18
- });
19
-
20
- it("handles duplicate headings", () => {
21
- const content = `## Section
22
- ## Section
23
- ## Section`;
24
-
25
- const { result } = renderHook(() => useHeadings(content, "markdown"));
26
-
27
- expect(result.current).toEqual([
28
- { id: "section", text: "Section", level: 2 },
29
- { id: "section-1", text: "Section", level: 2 },
30
- { id: "section-2", text: "Section", level: 2 },
31
- ]);
32
- });
33
-
34
- it("ignores headings inside fenced code blocks", () => {
35
- const content = `# Real Heading
36
-
37
- \`\`\`bash
38
- # This is a comment, not a heading
39
- echo "hello"
40
- \`\`\`
41
-
42
- ## Another Real Heading`;
43
-
44
- const { result } = renderHook(() => useHeadings(content, "markdown"));
45
-
46
- expect(result.current).toEqual([
47
- { id: "real-heading", text: "Real Heading", level: 1 },
48
- { id: "another-real-heading", text: "Another Real Heading", level: 2 },
49
- ]);
50
- });
51
-
52
- it("ignores headings inside triple-tilde code blocks", () => {
53
- const content = `# Real Heading
54
-
55
- ~~~python
56
- # Python comment
57
- def foo():
58
- pass
59
- ~~~
60
-
61
- ## Another Real Heading`;
62
-
63
- const { result } = renderHook(() => useHeadings(content, "markdown"));
64
-
65
- expect(result.current).toEqual([
66
- { id: "real-heading", text: "Real Heading", level: 1 },
67
- { id: "another-real-heading", text: "Another Real Heading", level: 2 },
68
- ]);
69
- });
70
-
71
- it("handles multiple code blocks", () => {
72
- const content = `# Introduction
73
-
74
- \`\`\`bash
75
- # Comment 1
76
- \`\`\`
77
-
78
- ## Methods
79
-
80
- \`\`\`python
81
- # Comment 2
82
- \`\`\`
83
-
84
- ## Results`;
85
-
86
- const { result } = renderHook(() => useHeadings(content, "markdown"));
87
-
88
- expect(result.current).toEqual([
89
- { id: "introduction", text: "Introduction", level: 1 },
90
- { id: "methods", text: "Methods", level: 2 },
91
- { id: "results", text: "Results", level: 2 },
92
- ]);
93
- });
94
-
95
- it("handles code block with language specifier", () => {
96
- const content = `# Setup
97
-
98
- \`\`\`bash
99
- # Use a custom port
100
- npx readit document.md --port 3000
101
- \`\`\`
102
-
103
- ## Usage`;
104
-
105
- const { result } = renderHook(() => useHeadings(content, "markdown"));
106
-
107
- expect(result.current).toEqual([
108
- { id: "setup", text: "Setup", level: 1 },
109
- { id: "usage", text: "Usage", level: 2 },
110
- ]);
111
- });
112
-
113
- it("returns empty array for null content", () => {
114
- const { result } = renderHook(() => useHeadings(null, "markdown"));
115
- expect(result.current).toEqual([]);
116
- });
117
-
118
- it("returns empty array for null type", () => {
119
- const { result } = renderHook(() => useHeadings("# Heading", null));
120
- expect(result.current).toEqual([]);
121
- });
122
- });
123
-
124
- describe("useHeadings - html", () => {
125
- it("extracts basic headings", () => {
126
- const content = `<h1>Heading 1</h1>
127
- <h2>Heading 2</h2>
128
- <h3>Heading 3</h3>`;
129
-
130
- const { result } = renderHook(() => useHeadings(content, "html"));
131
-
132
- expect(result.current).toEqual([
133
- { id: "heading-1", text: "Heading 1", level: 1 },
134
- { id: "heading-2", text: "Heading 2", level: 2 },
135
- { id: "heading-3", text: "Heading 3", level: 3 },
136
- ]);
137
- });
138
-
139
- it("uses existing id attribute", () => {
140
- const content = `<h1 id="custom-id">Heading 1</h1>`;
141
-
142
- const { result } = renderHook(() => useHeadings(content, "html"));
143
-
144
- expect(result.current).toEqual([
145
- { id: "custom-id", text: "Heading 1", level: 1 },
146
- ]);
147
- });
148
-
149
- it("decodes HTML entities", () => {
150
- const content = `<h1>Hello &amp; World</h1>`;
151
-
152
- const { result } = renderHook(() => useHeadings(content, "html"));
153
-
154
- // Note: & is stripped, leaving "Hello World" → "hello-world" (hyphens collapsed)
155
- expect(result.current).toEqual([
156
- { id: "hello-world", text: "Hello & World", level: 1 },
157
- ]);
158
- });
159
- });
@@ -1,129 +0,0 @@
1
- import { useMemo } from "react";
2
- import { slugify } from "../lib/utils";
3
- import type { DocumentType } from "../types";
4
-
5
- export interface Heading {
6
- id: string;
7
- text: string;
8
- level: 1 | 2 | 3 | 4 | 5 | 6;
9
- }
10
-
11
- /**
12
- * Remove code blocks from markdown content.
13
- * Handles both fenced (```) and indented (4 spaces) code blocks.
14
- */
15
- function stripCodeBlocks(content: string): string {
16
- // Remove fenced code blocks (``` or ~~~)
17
- let result = content.replace(/^(`{3,}|~{3,}).*$[\s\S]*?^\1\s*$/gm, "");
18
-
19
- // Remove indented code blocks (4 spaces or 1 tab at start of line)
20
- // Only remove if preceded by a blank line (to avoid removing list items)
21
- result = result.replace(/(?:^|\n\n)((?:(?:[ ]{4}|\t).+\n?)+)/g, "\n\n");
22
-
23
- return result;
24
- }
25
-
26
- /**
27
- * Extract headings from markdown content
28
- */
29
- function parseMarkdownHeadings(content: string): Heading[] {
30
- const headings: Heading[] = [];
31
- const seenIds = new Map<string, number>();
32
-
33
- // Strip code blocks to avoid matching # comments in code
34
- const contentWithoutCode = stripCodeBlocks(content);
35
-
36
- const regex = /^(#{1,6})\s+(.+)$/gm;
37
- let match = regex.exec(contentWithoutCode);
38
-
39
- while (match !== null) {
40
- const level = match[1].length as 1 | 2 | 3 | 4 | 5 | 6;
41
- const text = match[2].trim();
42
- const baseId = slugify(text);
43
- const count = seenIds.get(baseId) ?? 0;
44
- const id = count > 0 ? `${baseId}-${count}` : baseId;
45
- seenIds.set(baseId, count + 1);
46
-
47
- headings.push({ id, text, level });
48
- match = regex.exec(contentWithoutCode);
49
- }
50
-
51
- return headings;
52
- }
53
-
54
- /**
55
- * Generate ID matching the iframe's ensureHeadingIds algorithm.
56
- * Note: This differs from utils/slugify - it strips underscores to match
57
- * the iframe script's ID generation exactly.
58
- */
59
- function generateHeadingId(text: string): string {
60
- return text
61
- .toLowerCase()
62
- .trim()
63
- .replace(/[^a-z0-9 -]/g, "")
64
- .replace(/ +/g, "-")
65
- .replace(/-+/g, "-");
66
- }
67
-
68
- /**
69
- * Extract headings from HTML content
70
- */
71
- function parseHtmlHeadings(content: string): Heading[] {
72
- const headings: Heading[] = [];
73
- const seenIds = new Map<string, number>();
74
-
75
- // Match h1-h6 tags, capturing attributes and text content
76
- const regex = /<h([1-6])([^>]*)>([^<]+)<\/h\1>/gi;
77
- let match = regex.exec(content);
78
-
79
- while (match !== null) {
80
- const level = Number.parseInt(match[1], 10) as 1 | 2 | 3 | 4 | 5 | 6;
81
- const attributes = match[2];
82
- // Strip any remaining HTML tags and decode entities
83
- const text = match[3]
84
- .replace(/<[^>]+>/g, "")
85
- .replace(/&amp;/g, "&")
86
- .replace(/&lt;/g, "<")
87
- .replace(/&gt;/g, ">")
88
- .replace(/&quot;/g, '"')
89
- .trim();
90
-
91
- if (text) {
92
- // Extract existing id attribute if present
93
- const idMatch = /\sid=["']([^"']+)["']/i.exec(attributes);
94
-
95
- // Use existing ID or generate one with duplicate handling
96
- const id = idMatch
97
- ? idMatch[1]
98
- : (() => {
99
- const baseId = generateHeadingId(text);
100
- const count = seenIds.get(baseId) ?? 0;
101
- seenIds.set(baseId, count + 1);
102
- return count > 0 ? `${baseId}-${count}` : baseId;
103
- })();
104
-
105
- headings.push({ id, text, level });
106
- }
107
- match = regex.exec(content);
108
- }
109
-
110
- return headings;
111
- }
112
-
113
- /**
114
- * Hook to extract headings from document content
115
- */
116
- export function useHeadings(
117
- content: string | null,
118
- type: DocumentType | null,
119
- ): Heading[] {
120
- return useMemo(() => {
121
- if (!content || !type) return [];
122
-
123
- if (type === "markdown") {
124
- return parseMarkdownHeadings(content);
125
- }
126
-
127
- return parseHtmlHeadings(content);
128
- }, [content, type]);
129
- }
@@ -1,108 +0,0 @@
1
- import { useCallback, useEffect, useState } from "react";
2
- import { toast } from "sonner";
3
- import {
4
- resolveShortcuts,
5
- type ShortcutDefinition,
6
- } from "../lib/shortcut-registry";
7
- import type { KeybindingOverride, ShortcutBinding } from "../types";
8
-
9
- interface UseKeybindingsResult {
10
- shortcuts: ShortcutDefinition[];
11
- updateBinding: (id: string, binding: ShortcutBinding) => Promise<void>;
12
- toggleEnabled: (id: string) => Promise<void>;
13
- resetToDefaults: () => Promise<void>;
14
- isLoading: boolean;
15
- }
16
-
17
- export function useKeybindings(): UseKeybindingsResult {
18
- const [overrides, setOverrides] = useState<KeybindingOverride[]>([]);
19
- const [isLoading, setIsLoading] = useState(true);
20
-
21
- useEffect(() => {
22
- const fetchKeybindings = async () => {
23
- try {
24
- const response = await fetch("/api/settings");
25
- if (response.ok) {
26
- const settings = await response.json();
27
- setOverrides(settings.keybindings ?? []);
28
- }
29
- } catch (err) {
30
- console.error("Failed to fetch keybindings:", err);
31
- } finally {
32
- setIsLoading(false);
33
- }
34
- };
35
-
36
- fetchKeybindings();
37
- }, []);
38
-
39
- const persistOverrides = useCallback(
40
- async (newOverrides: KeybindingOverride[]) => {
41
- try {
42
- const response = await fetch("/api/settings");
43
- if (!response.ok) return;
44
-
45
- const currentSettings = await response.json();
46
- const updated = { ...currentSettings, keybindings: newOverrides };
47
-
48
- const putResponse = await fetch("/api/settings", {
49
- method: "PUT",
50
- headers: { "Content-Type": "application/json" },
51
- body: JSON.stringify(updated),
52
- });
53
-
54
- if (!putResponse.ok) {
55
- throw new Error("Failed to save keybindings");
56
- }
57
- } catch (err) {
58
- console.error("Failed to save keybindings:", err);
59
- toast.error("Failed to save keybindings");
60
- }
61
- },
62
- [],
63
- );
64
-
65
- const updateBinding = useCallback(
66
- async (id: string, binding: ShortcutBinding) => {
67
- const newOverrides = overrides.filter((o) => o.id !== id);
68
- newOverrides.push({ id, binding, enabled: true });
69
-
70
- setOverrides(newOverrides);
71
- await persistOverrides(newOverrides);
72
- },
73
- [overrides, persistOverrides],
74
- );
75
-
76
- const toggleEnabled = useCallback(
77
- async (id: string) => {
78
- const existing = overrides.find((o) => o.id === id);
79
- const currentEnabled = existing?.enabled ?? true;
80
- const newOverrides = overrides.filter((o) => o.id !== id);
81
- newOverrides.push({
82
- id,
83
- binding: existing?.binding,
84
- enabled: !currentEnabled,
85
- });
86
-
87
- setOverrides(newOverrides);
88
- await persistOverrides(newOverrides);
89
- },
90
- [overrides, persistOverrides],
91
- );
92
-
93
- const resetToDefaults = useCallback(async () => {
94
- setOverrides([]);
95
- await persistOverrides([]);
96
- toast.success("Keyboard shortcuts reset to defaults");
97
- }, [persistOverrides]);
98
-
99
- const shortcuts = resolveShortcuts(overrides);
100
-
101
- return {
102
- shortcuts,
103
- updateBinding,
104
- toggleEnabled,
105
- resetToDefaults,
106
- isLoading,
107
- };
108
- }