@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,95 +0,0 @@
1
- import type { Element, Root } from "hast";
2
- import { TriangleAlert } from "lucide-react";
3
- import type { ReactNode } from "react";
4
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
- import rehypeParse from "rehype-parse";
6
- import rehypeReact from "rehype-react";
7
- import { unified } from "unified";
8
- import { visit } from "unist-util-visit";
9
-
10
- /**
11
- * Placeholder component for stripped dangerous elements
12
- */
13
- function StrippedElement({ tagName }: { tagName: string }) {
14
- return (
15
- <span className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-red-50 text-red-700 border border-red-200 rounded-md font-mono">
16
- <TriangleAlert className="w-4 h-4" />
17
- &lt;{tagName}&gt; removed
18
- </span>
19
- );
20
- }
21
-
22
- /**
23
- * Component mappings for dangerous elements - renders placeholders instead
24
- */
25
- const dangerousElementComponents = {
26
- script: () => <StrippedElement tagName="script" />,
27
- // Style tags targeting body/html/* can leak outside Shadow DOM, so strip them
28
- // Our base styles in ShadowContainer provide typography instead
29
- style: () => null,
30
- link: () => null, // External stylesheets break app styles
31
- iframe: () => <StrippedElement tagName="iframe" />,
32
- object: () => <StrippedElement tagName="object" />,
33
- embed: () => <StrippedElement tagName="embed" />,
34
- frame: () => <StrippedElement tagName="frame" />,
35
- frameset: () => <StrippedElement tagName="frameset" />,
36
- };
37
-
38
- /**
39
- * Rehype plugin to strip event handler attributes (onclick, onerror, etc.)
40
- * and dangerous href/src values (javascript:, data:, vbscript:)
41
- */
42
- function rehypeStripDangerousAttributes() {
43
- const dangerousSchemes = /^(javascript|vbscript|data):/i;
44
-
45
- return (tree: Root) => {
46
- visit(tree, "element", (node: Element) => {
47
- const props = node.properties;
48
- if (!props) return;
49
-
50
- for (const key of Object.keys(props)) {
51
- // Strip all event handlers (on*)
52
- if (key.startsWith("on") || key.startsWith("On")) {
53
- delete props[key];
54
- continue;
55
- }
56
-
57
- // Neutralize dangerous href/src schemes
58
- if (key === "href" || key === "src") {
59
- const value = props[key];
60
- if (
61
- typeof value === "string" &&
62
- dangerousSchemes.test(value.trim())
63
- ) {
64
- props[key] = "#";
65
- }
66
- }
67
- }
68
- });
69
- };
70
- }
71
-
72
- /**
73
- * Create the unified processor for HTML -> React conversion
74
- */
75
- const processor = unified()
76
- .use(rehypeParse, { fragment: true })
77
- .use(rehypeStripDangerousAttributes)
78
- .use(rehypeReact, {
79
- jsx,
80
- jsxs,
81
- Fragment,
82
- components: dangerousElementComponents,
83
- });
84
-
85
- /**
86
- * Process HTML content and return safe React elements
87
- *
88
- * - Dangerous tags (script, iframe, etc.) become visible placeholders
89
- * - Event handlers (onclick, onerror, etc.) are stripped
90
- * - Dangerous URLs (javascript:, data:, etc.) are neutralized
91
- */
92
- export function processHtml(content: string): ReactNode {
93
- const result = processor.processSync(content);
94
- return result.result as ReactNode;
95
- }
@@ -1,51 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { en } from "./en";
3
- import { ja } from "./ja";
4
-
5
- describe("translation completeness", () => {
6
- const enKeys = Object.keys(en).sort();
7
- const jaKeys = Object.keys(ja).sort();
8
-
9
- it("en and ja have the same keys", () => {
10
- expect(enKeys).toEqual(jaKeys);
11
- });
12
-
13
- // Prefix/suffix keys may be intentionally empty in some locales
14
- // (e.g., Japanese has no prefix before the command)
15
- const ALLOW_EMPTY = new Set(["app.noDocumentsHintPrefix"]);
16
-
17
- it("no empty string values in en", () => {
18
- for (const [key, value] of Object.entries(en)) {
19
- if (ALLOW_EMPTY.has(key)) continue;
20
- expect(value, `en.${key} is empty`).not.toBe("");
21
- }
22
- });
23
-
24
- it("no empty string values in ja", () => {
25
- for (const [key, value] of Object.entries(ja)) {
26
- if (ALLOW_EMPTY.has(key)) continue;
27
- expect(value, `ja.${key} is empty`).not.toBe("");
28
- }
29
- });
30
-
31
- it("interpolation placeholders match between locales", () => {
32
- const placeholderPattern = /\{\{(\w+)\}\}/g;
33
-
34
- for (const key of enKeys) {
35
- const enValue = en[key as keyof typeof en];
36
- const jaValue = ja[key as keyof typeof ja];
37
-
38
- const enPlaceholders = [...enValue.matchAll(placeholderPattern)]
39
- .map((m) => m[1])
40
- .sort();
41
- const jaPlaceholders = [...jaValue.matchAll(placeholderPattern)]
42
- .map((m) => m[1])
43
- .sort();
44
-
45
- expect(
46
- enPlaceholders,
47
- `Placeholder mismatch for key "${key}": en has ${JSON.stringify(enPlaceholders)}, ja has ${JSON.stringify(jaPlaceholders)}`,
48
- ).toEqual(jaPlaceholders);
49
- }
50
- });
51
- });
@@ -1,39 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { createT } from "./translations";
3
- import { Locales } from "./types";
4
-
5
- describe("createT", () => {
6
- it("returns English strings for en locale", () => {
7
- const t = createT(Locales.EN);
8
- expect(t("app.loading")).toBe("Loading...");
9
- expect(t("settings.title")).toBe("Settings");
10
- expect(t("comment.placeholder")).toBe("Add your comment...");
11
- });
12
-
13
- it("returns Japanese strings for ja locale", () => {
14
- const t = createT(Locales.JA);
15
- expect(t("app.loading")).toBe("読み込み中...");
16
- expect(t("settings.title")).toBe("設定");
17
- expect(t("comment.placeholder")).toBe("コメントを入力...");
18
- });
19
-
20
- it("interpolates {{placeholder}} params", () => {
21
- const tEn = createT(Locales.EN);
22
- expect(tEn("commentNav.of", { current: 1, total: 5 })).toBe("1 of 5");
23
-
24
- const tJa = createT(Locales.JA);
25
- expect(tJa("commentNav.of", { current: 1, total: 5 })).toBe("1 / 5");
26
- });
27
-
28
- it("interpolates multiple params", () => {
29
- const t = createT(Locales.EN);
30
- expect(t("commentManager.deleteAllConfirm", { count: 3 })).toBe(
31
- "Delete all 3 comments?",
32
- );
33
- });
34
-
35
- it("returns string unchanged when no params provided", () => {
36
- const t = createT(Locales.EN);
37
- expect(t("app.footer")).toBe("Made with ❤️ by Jay and Claude");
38
- });
39
- });
@@ -1,12 +0,0 @@
1
- // Layout constants for margin notes and document viewer
2
-
3
- export const HEADER_HEIGHT_PX = 48;
4
-
5
- // Minimum gap between margin notes (accounts for quote + comment + actions + padding)
6
- export const MARGIN_NOTE_MIN_GAP_PX = 150;
7
-
8
- // Height reserved for comment input form when selecting text
9
- export const COMMENT_INPUT_HEIGHT_PX = 160;
10
-
11
- // Minimap starts below the header
12
- export const MINIMAP_HEADER_OFFSET_PX = HEADER_HEIGHT_PX;
@@ -1,118 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { calculateScrollTarget, getElementTopInDocument } from "./scroll";
3
-
4
- describe("calculateScrollTarget", () => {
5
- it("positions element at 25% from top by default", () => {
6
- // Element at 1000px, viewport 800px
7
- // Target offset = 800 * 0.25 = 200px
8
- // Scroll target = 1000 - 200 = 800px
9
- expect(
10
- calculateScrollTarget({ elementTop: 1000, viewportHeight: 800 }),
11
- ).toBe(800);
12
- });
13
-
14
- it("respects custom offset percent", () => {
15
- // Element at 1000px, viewport 800px, offset 50%
16
- // Target offset = 800 * 0.5 = 400px
17
- // Scroll target = 1000 - 400 = 600px
18
- expect(
19
- calculateScrollTarget({
20
- elementTop: 1000,
21
- viewportHeight: 800,
22
- offsetPercent: 0.5,
23
- }),
24
- ).toBe(600);
25
- });
26
-
27
- it("returns 0 when element is near top", () => {
28
- // Element at 100px, viewport 800px
29
- // Target offset = 200px
30
- // Scroll target = max(0, 100 - 200) = 0
31
- expect(
32
- calculateScrollTarget({ elementTop: 100, viewportHeight: 800 }),
33
- ).toBe(0);
34
- });
35
-
36
- it("returns 0 for element at position 0", () => {
37
- expect(calculateScrollTarget({ elementTop: 0, viewportHeight: 800 })).toBe(
38
- 0,
39
- );
40
- });
41
-
42
- it("handles small viewport", () => {
43
- // Element at 500px, viewport 400px
44
- // Target offset = 100px
45
- // Scroll target = 500 - 100 = 400px
46
- expect(
47
- calculateScrollTarget({ elementTop: 500, viewportHeight: 400 }),
48
- ).toBe(400);
49
- });
50
-
51
- it("handles zero offset percent", () => {
52
- // Element at 1000px, no offset
53
- // Scroll target = 1000px (element at very top of viewport)
54
- expect(
55
- calculateScrollTarget({
56
- elementTop: 1000,
57
- viewportHeight: 800,
58
- offsetPercent: 0,
59
- }),
60
- ).toBe(1000);
61
- });
62
- });
63
-
64
- describe("getElementTopInDocument", () => {
65
- it("calculates position for element in main document (not scrolled)", () => {
66
- // Element at 500px from viewport top, no scroll
67
- const elementRect = { top: 500 };
68
- expect(getElementTopInDocument({ elementRect, scrollY: 0 })).toBe(500);
69
- });
70
-
71
- it("calculates position for element in main document (scrolled)", () => {
72
- // Element at 200px from viewport top, scrolled 300px
73
- // Absolute position = 300 + 200 = 500px
74
- const elementRect = { top: 200 };
75
- expect(getElementTopInDocument({ elementRect, scrollY: 300 })).toBe(500);
76
- });
77
-
78
- it("calculates position for element inside iframe", () => {
79
- // Iframe at 100px from viewport top
80
- // Element at 150px from iframe top (inside iframe)
81
- // Scrolled 50px
82
- // Absolute position = 50 + 100 + 150 = 300px
83
- const elementRect = { top: 150 };
84
- expect(
85
- getElementTopInDocument({
86
- elementRect,
87
- scrollY: 50,
88
- iframeTopOffset: 100,
89
- }),
90
- ).toBe(300);
91
- });
92
-
93
- it("handles element at viewport top with scroll", () => {
94
- // Element at 0px from viewport top, scrolled 1000px
95
- const elementRect = { top: 0 };
96
- expect(getElementTopInDocument({ elementRect, scrollY: 1000 })).toBe(1000);
97
- });
98
-
99
- it("handles negative element position (above viewport)", () => {
100
- // Element scrolled past viewport top
101
- const elementRect = { top: -100 };
102
- expect(getElementTopInDocument({ elementRect, scrollY: 500 })).toBe(400);
103
- });
104
-
105
- it("handles iframe with element above iframe viewport", () => {
106
- // Iframe at 200px, element at -50px within iframe (scrolled past)
107
- // scrollY = 100
108
- // Absolute position = 100 + 200 + (-50) = 250px
109
- const elementRect = { top: -50 };
110
- expect(
111
- getElementTopInDocument({
112
- elementRect,
113
- scrollY: 100,
114
- iframeTopOffset: 200,
115
- }),
116
- ).toBe(250);
117
- });
118
- });
package/src/lib/scroll.ts DELETED
@@ -1,47 +0,0 @@
1
- /**
2
- * Scroll calculation utilities for TOC navigation.
3
- * These pure functions enable unit testing of scroll position calculations.
4
- */
5
-
6
- /**
7
- * Parameters for scroll target calculation.
8
- * Using object destructuring per style guide §3.5 for clarity.
9
- */
10
- export interface CalculateScrollTargetParams {
11
- elementTop: number;
12
- viewportHeight: number;
13
- offsetPercent?: number;
14
- }
15
-
16
- export interface GetElementTopParams {
17
- elementRect: { top: number };
18
- scrollY: number;
19
- iframeTopOffset?: number;
20
- }
21
-
22
- /**
23
- * Calculate the scroll target position to place an element at a comfortable
24
- * reading position (default: 25% from top of viewport).
25
- */
26
- export function calculateScrollTarget({
27
- elementTop,
28
- viewportHeight,
29
- offsetPercent = 0.25,
30
- }: CalculateScrollTargetParams): number {
31
- const targetOffset = viewportHeight * offsetPercent;
32
- return Math.max(0, elementTop - targetOffset);
33
- }
34
-
35
- /**
36
- * Get an element's absolute position in the main document.
37
- *
38
- * For elements directly in the document: pass scrollY and the element.
39
- * For elements inside an iframe: also pass the iframe's top offset.
40
- */
41
- export function getElementTopInDocument({
42
- elementRect,
43
- scrollY,
44
- iframeTopOffset,
45
- }: GetElementTopParams): number {
46
- return scrollY + (iframeTopOffset ?? 0) + elementRect.top;
47
- }
@@ -1,173 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- DEFAULT_SHORTCUTS,
4
- formatBinding,
5
- isReservedBinding,
6
- type KeybindingOverride,
7
- matchesBinding,
8
- resolveShortcuts,
9
- ShortcutActions,
10
- type ShortcutBinding,
11
- } from "./shortcut-registry";
12
-
13
- describe("DEFAULT_SHORTCUTS", () => {
14
- it("defines 7 shortcuts", () => {
15
- expect(DEFAULT_SHORTCUTS).toHaveLength(7);
16
- });
17
-
18
- it("has unique IDs", () => {
19
- const ids = DEFAULT_SHORTCUTS.map((s) => s.id);
20
- expect(new Set(ids).size).toBe(ids.length);
21
- });
22
-
23
- it("all enabled by default", () => {
24
- for (const shortcut of DEFAULT_SHORTCUTS) {
25
- expect(shortcut.enabled).toBe(true);
26
- }
27
- });
28
- });
29
-
30
- describe("matchesBinding", () => {
31
- it("matches exact key + modifiers", () => {
32
- const binding: ShortcutBinding = { key: "c", alt: true };
33
- const event = new KeyboardEvent("keydown", { key: "c", altKey: true });
34
- expect(matchesBinding(event, binding)).toBe(true);
35
- });
36
-
37
- it("rejects wrong key", () => {
38
- const binding: ShortcutBinding = { key: "c", alt: true };
39
- const event = new KeyboardEvent("keydown", { key: "v", altKey: true });
40
- expect(matchesBinding(event, binding)).toBe(false);
41
- });
42
-
43
- it("rejects extra modifiers", () => {
44
- const binding: ShortcutBinding = { key: "c", alt: true };
45
- const event = new KeyboardEvent("keydown", {
46
- key: "c",
47
- altKey: true,
48
- shiftKey: true,
49
- });
50
- expect(matchesBinding(event, binding)).toBe(false);
51
- });
52
-
53
- it("matches binding with shift", () => {
54
- const binding: ShortcutBinding = { key: "c", alt: true, shift: true };
55
- const event = new KeyboardEvent("keydown", {
56
- key: "c",
57
- altKey: true,
58
- shiftKey: true,
59
- });
60
- expect(matchesBinding(event, binding)).toBe(true);
61
- });
62
-
63
- it("matches meta key", () => {
64
- const binding: ShortcutBinding = { key: "c", meta: true };
65
- const event = new KeyboardEvent("keydown", { key: "c", metaKey: true });
66
- expect(matchesBinding(event, binding)).toBe(true);
67
- });
68
-
69
- it("matches shifted letter key (browser reports uppercase)", () => {
70
- const binding: ShortcutBinding = { key: "c", alt: true, shift: true };
71
- const event = new KeyboardEvent("keydown", {
72
- key: "C",
73
- altKey: true,
74
- shiftKey: true,
75
- });
76
- expect(matchesBinding(event, binding)).toBe(true);
77
- });
78
-
79
- it("matches meta+shift letter key (browser reports uppercase)", () => {
80
- const binding: ShortcutBinding = { key: "c", meta: true, shift: true };
81
- const event = new KeyboardEvent("keydown", {
82
- key: "C",
83
- metaKey: true,
84
- shiftKey: true,
85
- });
86
- expect(matchesBinding(event, binding)).toBe(true);
87
- });
88
-
89
- it("matches key-only binding (no modifiers)", () => {
90
- const binding: ShortcutBinding = { key: "Escape" };
91
- const event = new KeyboardEvent("keydown", { key: "Escape" });
92
- expect(matchesBinding(event, binding)).toBe(true);
93
- });
94
- });
95
-
96
- describe("formatBinding", () => {
97
- it("formats Alt+C", () => {
98
- const binding: ShortcutBinding = { key: "c", alt: true };
99
- expect(formatBinding(binding, true)).toBe("Alt+C");
100
- });
101
-
102
- it("formats meta key as ⌘ on Mac", () => {
103
- const binding: ShortcutBinding = { key: "c", meta: true };
104
- expect(formatBinding(binding, true)).toBe("⌘+C");
105
- });
106
-
107
- it("formats meta key as Ctrl on non-Mac", () => {
108
- const binding: ShortcutBinding = { key: "c", meta: true };
109
- expect(formatBinding(binding, false)).toBe("Ctrl+C");
110
- });
111
-
112
- it("formats shift modifier", () => {
113
- const binding: ShortcutBinding = { key: "c", meta: true, shift: true };
114
- expect(formatBinding(binding, true)).toBe("⌘+Shift+C");
115
- });
116
-
117
- it("formats arrow keys with symbols", () => {
118
- const binding: ShortcutBinding = { key: "ArrowUp", alt: true };
119
- expect(formatBinding(binding, true)).toBe("Alt+↑");
120
- });
121
-
122
- it("formats Escape", () => {
123
- const binding: ShortcutBinding = { key: "Escape" };
124
- expect(formatBinding(binding, true)).toBe("Esc");
125
- });
126
- });
127
-
128
- describe("resolveShortcuts", () => {
129
- it("returns defaults when no overrides", () => {
130
- const resolved = resolveShortcuts([]);
131
- expect(resolved).toEqual(DEFAULT_SHORTCUTS);
132
- });
133
-
134
- it("applies enabled override", () => {
135
- const overrides: KeybindingOverride[] = [
136
- { id: ShortcutActions.COPY_ALL, enabled: false },
137
- ];
138
- const resolved = resolveShortcuts(overrides);
139
- const copyAll = resolved.find((s) => s.id === ShortcutActions.COPY_ALL);
140
- expect(copyAll?.enabled).toBe(false);
141
- });
142
-
143
- it("applies binding override", () => {
144
- const overrides: KeybindingOverride[] = [
145
- {
146
- id: ShortcutActions.COPY_ALL,
147
- enabled: true,
148
- binding: { key: "a", meta: true },
149
- },
150
- ];
151
- const resolved = resolveShortcuts(overrides);
152
- const copyAll = resolved.find((s) => s.id === ShortcutActions.COPY_ALL);
153
- expect(copyAll?.binding).toEqual({ key: "a", meta: true });
154
- });
155
-
156
- it("ignores unknown override IDs", () => {
157
- const overrides: KeybindingOverride[] = [
158
- { id: "unknown_action", enabled: false },
159
- ];
160
- const resolved = resolveShortcuts(overrides);
161
- expect(resolved).toEqual(DEFAULT_SHORTCUTS);
162
- });
163
- });
164
-
165
- describe("isReservedBinding", () => {
166
- it("detects ⌘+W as reserved", () => {
167
- expect(isReservedBinding({ key: "w", meta: true })).toBe(true);
168
- });
169
-
170
- it("allows Alt+C", () => {
171
- expect(isReservedBinding({ key: "c", alt: true })).toBe(false);
172
- });
173
- });
@@ -1,110 +0,0 @@
1
- import { createElement } from "react";
2
- import { describe, expect, it } from "vitest";
3
- import { getTextContent, slugify } from "./utils";
4
-
5
- describe("slugify", () => {
6
- it("converts text to lowercase", () => {
7
- expect(slugify("Hello World")).toBe("hello-world");
8
- });
9
-
10
- it("replaces spaces with hyphens", () => {
11
- expect(slugify("hello world")).toBe("hello-world");
12
- });
13
-
14
- it("removes special characters", () => {
15
- expect(slugify("Hello, World!")).toBe("hello-world");
16
- });
17
-
18
- it("handles multiple spaces", () => {
19
- expect(slugify("hello world")).toBe("hello-world");
20
- });
21
-
22
- it("handles leading/trailing whitespace", () => {
23
- expect(slugify(" hello world ")).toBe("hello-world");
24
- });
25
-
26
- it("preserves underscores", () => {
27
- expect(slugify("hello_world")).toBe("hello_world");
28
- });
29
-
30
- it("handles hyphens", () => {
31
- expect(slugify("hello-world")).toBe("hello-world");
32
- });
33
-
34
- it("collapses multiple hyphens", () => {
35
- expect(slugify("hello---world")).toBe("hello-world");
36
- });
37
- });
38
-
39
- describe("getTextContent", () => {
40
- it("returns string children directly", () => {
41
- expect(getTextContent("Hello")).toBe("Hello");
42
- });
43
-
44
- it("converts number children to string", () => {
45
- expect(getTextContent(123)).toBe("123");
46
- });
47
-
48
- it("joins array of strings", () => {
49
- expect(getTextContent(["Hello", " ", "World"])).toBe("Hello World");
50
- });
51
-
52
- it("extracts text from React element", () => {
53
- const element = createElement("strong", null, "Bold");
54
- expect(getTextContent(element)).toBe("Bold");
55
- });
56
-
57
- it("extracts text from nested React elements", () => {
58
- const element = createElement(
59
- "span",
60
- null,
61
- "Hello ",
62
- createElement("strong", null, "World"),
63
- );
64
- expect(getTextContent(element)).toBe("Hello World");
65
- });
66
-
67
- it("handles mixed array of strings and elements", () => {
68
- const children = ["Hello ", createElement("strong", null, "World"), "!"];
69
- expect(getTextContent(children)).toBe("Hello World!");
70
- });
71
-
72
- it("handles deeply nested elements", () => {
73
- const element = createElement(
74
- "div",
75
- null,
76
- createElement("span", null, createElement("strong", null, "Deep")),
77
- );
78
- expect(getTextContent(element)).toBe("Deep");
79
- });
80
-
81
- it("returns empty string for null", () => {
82
- expect(getTextContent(null)).toBe("");
83
- });
84
-
85
- it("returns empty string for undefined", () => {
86
- expect(getTextContent(undefined)).toBe("");
87
- });
88
-
89
- it("returns empty string for boolean", () => {
90
- expect(getTextContent(true)).toBe("");
91
- expect(getTextContent(false)).toBe("");
92
- });
93
-
94
- it("handles markdown-style inline formatting", () => {
95
- // Simulates what react-markdown produces for "## Hello **World**"
96
- const children = ["Hello ", createElement("strong", null, "World")];
97
- expect(getTextContent(children)).toBe("Hello World");
98
- });
99
-
100
- it("handles complex markdown with multiple formatting", () => {
101
- // Simulates "## Hello **World** and _italic_"
102
- const children = [
103
- "Hello ",
104
- createElement("strong", null, "World"),
105
- " and ",
106
- createElement("em", null, "italic"),
107
- ];
108
- expect(getTextContent(children)).toBe("Hello World and italic");
109
- });
110
- });
package/src/main.tsx DELETED
@@ -1,13 +0,0 @@
1
- import React from "react";
2
- import ReactDOM from "react-dom/client";
3
- import App from "./App";
4
- import { LocaleProvider } from "./contexts/LocaleContext";
5
- import "./index.css";
6
-
7
- ReactDOM.createRoot(document.getElementById("root")!).render(
8
- <React.StrictMode>
9
- <LocaleProvider>
10
- <App />
11
- </LocaleProvider>
12
- </React.StrictMode>,
13
- );