@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.
Files changed (118) hide show
  1. package/README.md +0 -3
  2. package/biome.json +1 -1
  3. package/bun.lock +43 -185
  4. package/docs/perf-baseline.md +75 -0
  5. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  6. package/e2e/perf/add-comment.spec.ts +118 -0
  7. package/e2e/perf/fixtures/generate.ts +331 -0
  8. package/e2e/perf/initial-load.spec.ts +49 -0
  9. package/e2e/perf/perf.setup.ts +23 -0
  10. package/e2e/perf/perf.teardown.ts +9 -0
  11. package/e2e/perf/scroll.spec.ts +39 -0
  12. package/e2e/perf/tab-switch.spec.ts +69 -0
  13. package/e2e/perf/text-selection.spec.ts +119 -0
  14. package/e2e/perf/utils/metrics.ts +286 -0
  15. package/e2e/perf/utils/perf-cli.ts +86 -0
  16. package/package.json +9 -18
  17. package/playwright.config.ts +12 -0
  18. package/src/App.tsx +124 -172
  19. package/src/{cli/index.ts → cli.ts} +37 -53
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +77 -106
  22. package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
  23. package/src/components/Header.tsx +9 -20
  24. package/src/components/InlineEditor.tsx +5 -5
  25. package/src/components/MarginNote.tsx +71 -93
  26. package/src/components/MarginNotes.tsx +7 -34
  27. package/src/components/RawModal.tsx +9 -8
  28. package/src/components/ReanchorConfirm.tsx +2 -2
  29. package/src/components/SettingsModal.tsx +11 -89
  30. package/src/components/TabBar.tsx +4 -4
  31. package/src/components/TableOfContents.tsx +5 -5
  32. package/src/components/comments/CommentInput.tsx +7 -35
  33. package/src/components/comments/CommentListItem.tsx +9 -11
  34. package/src/components/comments/CommentManager.tsx +53 -37
  35. package/src/components/comments/CommentNav.tsx +14 -14
  36. package/src/components/ui/ActionLink.tsx +14 -18
  37. package/src/components/ui/Button.tsx +42 -43
  38. package/src/components/ui/Dialog.tsx +73 -113
  39. package/src/components/ui/DropdownMenu.tsx +113 -69
  40. package/src/components/ui/Text.tsx +30 -37
  41. package/src/contexts/CommentContext.tsx +75 -106
  42. package/src/contexts/LocaleContext.tsx +45 -4
  43. package/src/contexts/PositionsContext.tsx +16 -0
  44. package/src/contexts/SettingsContext.tsx +133 -0
  45. package/src/hooks/useClickOutside.ts +0 -4
  46. package/src/hooks/useCommentNavigation.ts +6 -29
  47. package/src/hooks/useComments.ts +6 -18
  48. package/src/hooks/useDocument.ts +35 -34
  49. package/src/hooks/useHeadings.test.ts +8 -50
  50. package/src/hooks/useHeadings.ts +5 -88
  51. package/src/hooks/useScrollSpy.ts +10 -14
  52. package/src/hooks/useTextSelection.ts +1 -38
  53. package/src/lib/__fixtures__/bench-data.ts +1 -41
  54. package/src/lib/anchor.bench.ts +57 -67
  55. package/src/lib/anchor.test.ts +5 -1
  56. package/src/lib/anchor.ts +13 -93
  57. package/src/lib/comment-storage.test.ts +4 -4
  58. package/src/lib/comment-storage.ts +2 -46
  59. package/src/lib/export.ts +7 -13
  60. package/src/lib/highlight/core.test.ts +1 -1
  61. package/src/lib/highlight/dom.ts +5 -68
  62. package/src/lib/highlight/highlighter.ts +102 -262
  63. package/src/lib/highlight/resolver.ts +112 -0
  64. package/src/lib/highlight/types.ts +0 -35
  65. package/src/lib/highlight/worker.ts +45 -0
  66. package/src/lib/i18n/en.ts +1 -50
  67. package/src/lib/i18n/ja.ts +1 -50
  68. package/src/lib/i18n/types.ts +1 -49
  69. package/src/lib/margin-layout.ts +5 -27
  70. package/src/lib/positions.ts +150 -0
  71. package/src/lib/utils.ts +2 -19
  72. package/src/schema.ts +81 -0
  73. package/src/{server/index.ts → server.ts} +74 -74
  74. package/src/{store/index.ts → store.ts} +14 -46
  75. package/vite.config.ts +8 -0
  76. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  77. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  78. package/src/components/DocumentViewer/index.ts +0 -1
  79. package/src/components/FloatingTOC.tsx +0 -61
  80. package/src/components/ShortcutCapture.tsx +0 -48
  81. package/src/components/ShortcutList.tsx +0 -198
  82. package/src/components/comments/CommentMinimap.tsx +0 -62
  83. package/src/components/ui/ActionBar.tsx +0 -16
  84. package/src/components/ui/SeparatorDot.tsx +0 -9
  85. package/src/contexts/LayoutContext.tsx +0 -88
  86. package/src/hooks/useClipboard.ts +0 -82
  87. package/src/hooks/useEditorScheme.ts +0 -51
  88. package/src/hooks/useFontPreference.ts +0 -59
  89. package/src/hooks/useKeybindings.ts +0 -108
  90. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  91. package/src/hooks/useLayoutMode.ts +0 -44
  92. package/src/hooks/useLocalePreference.ts +0 -42
  93. package/src/hooks/useReanchorMode.ts +0 -33
  94. package/src/hooks/useScrollMetrics.ts +0 -56
  95. package/src/hooks/useThemePreference.ts +0 -66
  96. package/src/lib/comment-storage.bench.ts +0 -63
  97. package/src/lib/context.bench.ts +0 -41
  98. package/src/lib/context.test.ts +0 -224
  99. package/src/lib/context.ts +0 -193
  100. package/src/lib/editor-links.ts +0 -59
  101. package/src/lib/export.bench.ts +0 -35
  102. package/src/lib/highlight/colors.ts +0 -37
  103. package/src/lib/highlight/core.ts +0 -54
  104. package/src/lib/highlight/index.ts +0 -23
  105. package/src/lib/highlight/script-builder.ts +0 -485
  106. package/src/lib/html-processor.test.tsx +0 -170
  107. package/src/lib/html-processor.tsx +0 -95
  108. package/src/lib/i18n/completeness.test.ts +0 -51
  109. package/src/lib/i18n/translations.test.ts +0 -39
  110. package/src/lib/layout-constants.ts +0 -12
  111. package/src/lib/margin-layout.bench.ts +0 -28
  112. package/src/lib/scroll.test.ts +0 -118
  113. package/src/lib/scroll.ts +0 -47
  114. package/src/lib/shortcut-registry.test.ts +0 -173
  115. package/src/lib/shortcut-registry.ts +0 -209
  116. package/src/lib/utils.test.ts +0 -110
  117. package/src/store/index.test.ts +0 -242
  118. package/src/types/index.ts +0 -127
@@ -1,485 +0,0 @@
1
- /**
2
- * Builds the JavaScript script to be injected into the iframe.
3
- *
4
- * This script contains the core highlighting functions that run inside
5
- * the sandboxed iframe, communicating with the parent via postMessage.
6
- *
7
- * IMPORTANT: DUPLICATED FUNCTIONS
8
- * ================================
9
- * The following functions are duplicated from TypeScript sources.
10
- * They must be kept in sync manually. When modifying any of these
11
- * functions, update BOTH locations.
12
- *
13
- * Duplicated from core.ts:
14
- * - findTextPosition()
15
- *
16
- * Duplicated from dom.ts:
17
- * - getTextOffset()
18
- * - getDOMTextContent()
19
- * - collectTextNodes()
20
- * - applyHighlightToRange()
21
- * - clearHighlights()
22
- * - collectHighlightPositions() (viewport variant)
23
- *
24
- * Why duplication exists:
25
- * The iframe runs in a sandboxed environment and receives content
26
- * via srcdoc. It cannot import TypeScript modules. The functions
27
- * must be embedded as plain JavaScript strings.
28
- *
29
- * Keeping them in sync:
30
- * The TypeScript sources (core.ts, dom.ts) are the source of truth.
31
- * Tests in core.test.ts verify the behavior. If you change the
32
- * TypeScript implementation, manually update the corresponding
33
- * function here to match.
34
- */
35
-
36
- /**
37
- * Build the complete iframe script with parent origin for secure postMessage.
38
- */
39
- export function buildIframeScript(parentOrigin: string): string {
40
- return `
41
- <script>
42
- (function() {
43
- const parentOrigin = ${JSON.stringify(parentOrigin)};
44
- const root = document.body;
45
-
46
- // --- Core Functions (from core.ts) ---
47
-
48
- function findTextPosition(textContent, selectedText, hintOffset) {
49
- if (!selectedText || !textContent) {
50
- return null;
51
- }
52
-
53
- const occurrences = [];
54
- let idx = 0;
55
-
56
- for (;;) {
57
- idx = textContent.indexOf(selectedText, idx);
58
- if (idx === -1) break;
59
- occurrences.push(idx);
60
- idx += 1;
61
- }
62
-
63
- if (occurrences.length === 0) {
64
- return null;
65
- }
66
-
67
- if (occurrences.length === 1) {
68
- return {
69
- start: occurrences[0],
70
- end: occurrences[0] + selectedText.length,
71
- };
72
- }
73
-
74
- // Multiple occurrences: find closest to hint offset
75
- const target = hintOffset ?? 0;
76
- let closest = occurrences[0];
77
- let minDist = Math.abs(closest - target);
78
-
79
- for (const occ of occurrences) {
80
- const dist = Math.abs(occ - target);
81
- if (dist < minDist) {
82
- minDist = dist;
83
- closest = occ;
84
- }
85
- }
86
-
87
- return {
88
- start: closest,
89
- end: closest + selectedText.length,
90
- };
91
- }
92
-
93
- // --- DOM Functions (from dom.ts) ---
94
-
95
- const BLOCK_ELEMENTS = new Set([
96
- 'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
97
- 'PRE', 'BLOCKQUOTE', 'LI', 'TR', 'BR'
98
- ]);
99
-
100
- function findBlockParent(node) {
101
- let parent = node.parentElement;
102
- while (parent && !BLOCK_ELEMENTS.has(parent.tagName)) {
103
- parent = parent.parentElement;
104
- }
105
- return parent;
106
- }
107
-
108
- function getTextOffset(root, targetNode, targetOffset) {
109
- let offset = 0;
110
- let lastBlockParent = null;
111
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
112
-
113
- let node = walker.nextNode();
114
- while (node) {
115
- const blockParent = findBlockParent(node);
116
-
117
- // Add newline when transitioning between different block parents
118
- if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
119
- if (!lastBlockParent.contains(blockParent) && !blockParent.contains(lastBlockParent)) {
120
- offset += 1; // Account for the newline
121
- }
122
- }
123
-
124
- if (node === targetNode) {
125
- return offset + targetOffset;
126
- }
127
- offset += (node.textContent?.length ?? 0);
128
- lastBlockParent = blockParent;
129
- node = walker.nextNode();
130
- }
131
-
132
- return offset;
133
- }
134
-
135
- function getDOMTextContent(root) {
136
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
137
- let text = '';
138
- let lastBlockParent = null;
139
- let node = walker.nextNode();
140
-
141
- while (node) {
142
- const blockParent = findBlockParent(node);
143
-
144
- // Insert newline when transitioning between different block parents
145
- if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
146
- if (!lastBlockParent.contains(blockParent) && !blockParent.contains(lastBlockParent)) {
147
- text += '\\n';
148
- }
149
- }
150
-
151
- text += node.textContent ?? '';
152
- lastBlockParent = blockParent;
153
- node = walker.nextNode();
154
- }
155
-
156
- return text;
157
- }
158
-
159
- function collectTextNodes(root) {
160
- const textNodes = [];
161
- let currentOffset = 0;
162
- let lastBlockParent = null;
163
-
164
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
165
- let node = walker.nextNode();
166
-
167
- while (node) {
168
- const blockParent = findBlockParent(node);
169
-
170
- // Account for newline when transitioning between different block parents
171
- // (same logic as getDOMTextContent)
172
- if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
173
- if (!lastBlockParent.contains(blockParent) && !blockParent.contains(lastBlockParent)) {
174
- currentOffset += 1; // Account for the newline
175
- }
176
- }
177
-
178
- const length = node.textContent?.length ?? 0;
179
- textNodes.push({
180
- node: node,
181
- start: currentOffset,
182
- end: currentOffset + length,
183
- });
184
- currentOffset += length;
185
- lastBlockParent = blockParent;
186
- node = walker.nextNode();
187
- }
188
-
189
- return textNodes;
190
- }
191
-
192
- function applyHighlightToRange(root, startOffset, endOffset, style) {
193
- const textNodes = collectTextNodes(root);
194
-
195
- const overlappingNodes = textNodes.filter(
196
- n => n.end > startOffset && n.start < endOffset
197
- );
198
-
199
- if (overlappingNodes.length === 0) {
200
- return;
201
- }
202
-
203
- for (const { node: textNode, start } of overlappingNodes) {
204
- const nodeStart = Math.max(0, startOffset - start);
205
- const nodeEnd = Math.min(textNode.length, endOffset - start);
206
-
207
- if (nodeStart >= nodeEnd) {
208
- continue;
209
- }
210
-
211
- const range = document.createRange();
212
- range.setStart(textNode, nodeStart);
213
- range.setEnd(textNode, nodeEnd);
214
-
215
- const mark = document.createElement('mark');
216
- mark.setAttribute(style.attribute, style.attributeValue);
217
-
218
- try {
219
- range.surroundContents(mark);
220
- } catch (e) {
221
- // Range crosses element boundaries (e.g., syntax-highlighted code blocks)
222
- // Use extractContents + insertNode as fallback
223
- try {
224
- const fragment = range.extractContents();
225
- mark.appendChild(fragment);
226
- range.insertNode(mark);
227
- } catch (e2) {
228
- // If even extractContents fails, skip this node
229
- }
230
- }
231
- }
232
- }
233
-
234
- function clearHighlights(root) {
235
- const marks = root.querySelectorAll('mark[data-comment-id], mark[data-pending]');
236
-
237
- for (const mark of marks) {
238
- const parent = mark.parentNode;
239
- if (parent) {
240
- while (mark.firstChild) {
241
- parent.insertBefore(mark.firstChild, mark);
242
- }
243
- parent.removeChild(mark);
244
- }
245
- }
246
- }
247
-
248
- function collectHighlightPositions(root) {
249
- const positions = {};
250
- const documentPositions = {};
251
- const scrollY = window.scrollY || 0;
252
-
253
- const marks = root.querySelectorAll('mark[data-comment-id]');
254
- for (const mark of marks) {
255
- const commentId = mark.getAttribute('data-comment-id');
256
- if (!commentId || positions[commentId] !== undefined) continue;
257
-
258
- const rect = mark.getBoundingClientRect();
259
- positions[commentId] = rect.top;
260
- documentPositions[commentId] = rect.top + scrollY;
261
- }
262
-
263
- let pendingTop = null;
264
- const pendingMark = root.querySelector('mark[data-pending]');
265
- if (pendingMark) {
266
- const pendingRect = pendingMark.getBoundingClientRect();
267
- pendingTop = pendingRect.top;
268
- }
269
-
270
- return { positions, documentPositions, pendingTop };
271
- }
272
-
273
- // --- Selection Handler ---
274
-
275
- document.addEventListener('mouseup', function() {
276
- const selection = window.getSelection();
277
- if (!selection || selection.isCollapsed) return;
278
-
279
- // Normalize whitespace: collapse any sequence of whitespace containing newlines
280
- // Browser's selection.toString() includes CSS margins as extra newlines/spaces
281
- const text = selection.toString().trim().replace(/\\r?\\n\\s*/g, '\\n');
282
- if (text.length === 0) return;
283
-
284
- const range = selection.getRangeAt(0);
285
- const startOffset = getTextOffset(root, range.startContainer, range.startOffset);
286
- const endOffset = getTextOffset(root, range.endContainer, range.endOffset);
287
-
288
- const rangeRect = range.getBoundingClientRect();
289
- const selectionTop = rangeRect.top + document.documentElement.scrollTop;
290
-
291
- parent.postMessage({
292
- type: 'textSelection',
293
- text: text,
294
- startOffset: startOffset,
295
- endOffset: endOffset,
296
- selectionTop: selectionTop
297
- }, parentOrigin);
298
- });
299
-
300
- // --- Message Handler ---
301
-
302
- window.addEventListener('message', function(event) {
303
- // Handle scroll to heading request from parent
304
- if (event.data.type === 'scrollToHeading') {
305
- const id = event.data.id;
306
- const element = document.getElementById(id);
307
- if (element) {
308
- element.scrollIntoView({ behavior: 'smooth', block: 'start' });
309
- }
310
- return;
311
- }
312
-
313
- // Handle scroll to highlight request from parent
314
- if (event.data.type === 'scrollToHighlight') {
315
- const mark = document.querySelector('mark[data-comment-id="' + event.data.commentId + '"]');
316
- if (mark) {
317
- mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
318
- }
319
- return;
320
- }
321
-
322
- if (event.data.type === 'applyHighlights') {
323
- clearHighlights(root);
324
-
325
- const comments = event.data.comments || [];
326
- const pending = event.data.pendingSelection;
327
-
328
- const textContent = getDOMTextContent(root);
329
-
330
- // Resolve anchors and apply highlights
331
- const resolved = comments
332
- .map(function(c) {
333
- const anchor = findTextPosition(textContent, c.selectedText, c.startOffset);
334
- if (anchor) {
335
- return { id: c.id, startOffset: anchor.start, endOffset: anchor.end };
336
- }
337
- return { id: c.id, startOffset: c.startOffset, endOffset: c.endOffset };
338
- })
339
- .sort(function(a, b) { return a.startOffset - b.startOffset; });
340
-
341
- for (const comment of resolved) {
342
- applyHighlightToRange(root, comment.startOffset, comment.endOffset, {
343
- attribute: 'data-comment-id',
344
- attributeValue: comment.id
345
- });
346
- }
347
-
348
- if (pending) {
349
- applyHighlightToRange(root, pending.startOffset, pending.endOffset, {
350
- attribute: 'data-pending',
351
- attributeValue: 'true'
352
- });
353
- }
354
-
355
- setTimeout(function() {
356
- reportPositions();
357
- reportContentHeight();
358
- }, 50);
359
- }
360
- });
361
-
362
- // --- Position Reporting ---
363
-
364
- function reportPositions() {
365
- const result = collectHighlightPositions(root);
366
- parent.postMessage({
367
- type: 'highlightPositions',
368
- positions: result.positions,
369
- documentPositions: result.documentPositions,
370
- pendingTop: result.pendingTop
371
- }, parentOrigin);
372
- }
373
-
374
- // --- Content Height Reporting ---
375
-
376
- function reportContentHeight() {
377
- parent.postMessage({
378
- type: 'contentHeight',
379
- height: document.body.scrollHeight
380
- }, parentOrigin);
381
- }
382
-
383
- window.addEventListener('scroll', reportPositions, { passive: true });
384
- document.addEventListener('scroll', reportPositions, { passive: true });
385
- window.addEventListener('resize', function() {
386
- reportPositions();
387
- reportContentHeight();
388
- });
389
- window.addEventListener('load', reportContentHeight);
390
-
391
- // --- Hover Handlers ---
392
-
393
- document.addEventListener('mouseover', function(e) {
394
- const mark = e.target.closest('mark[data-comment-id]');
395
- if (mark) {
396
- parent.postMessage({
397
- type: 'highlightHover',
398
- commentId: mark.getAttribute('data-comment-id')
399
- }, parentOrigin);
400
- }
401
- });
402
-
403
- document.addEventListener('mouseout', function(e) {
404
- const mark = e.target.closest('mark[data-comment-id]');
405
- if (mark) {
406
- const related = e.relatedTarget?.closest?.('mark[data-comment-id]');
407
- if (!related || related.getAttribute('data-comment-id') !== mark.getAttribute('data-comment-id')) {
408
- parent.postMessage({ type: 'highlightHover', commentId: null }, parentOrigin);
409
- }
410
- }
411
- });
412
-
413
- document.addEventListener('click', function(e) {
414
- const mark = e.target.closest('mark[data-comment-id]');
415
- if (mark) {
416
- parent.postMessage({
417
- type: 'highlightClick',
418
- commentId: mark.getAttribute('data-comment-id')
419
- }, parentOrigin);
420
- }
421
- });
422
-
423
- // --- Ready Signal ---
424
-
425
- parent.postMessage({ type: 'iframeReady' }, parentOrigin);
426
-
427
- // --- Ensure Heading IDs for TOC navigation ---
428
-
429
- function ensureHeadingIds() {
430
- const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
431
- const seenIds = {};
432
-
433
- for (const heading of headings) {
434
- if (!heading.id) {
435
- let id = (heading.textContent || '')
436
- .toLowerCase()
437
- .trim()
438
- .replace(/[^a-z0-9 -]/g, '')
439
- .replace(/ +/g, '-')
440
- .replace(/-+/g, '-');
441
-
442
- // Handle duplicates
443
- const baseId = id;
444
- const count = seenIds[baseId] || 0;
445
- if (count > 0) {
446
- id = baseId + '-' + count;
447
- }
448
- seenIds[baseId] = count + 1;
449
-
450
- heading.id = id;
451
- }
452
- }
453
- }
454
- ensureHeadingIds();
455
-
456
- // Height reporting delays to catch layout shifts
457
- const HEIGHT_REPORT_DELAY_SHORT = 100;
458
- const HEIGHT_REPORT_DELAY_LONG = 500;
459
-
460
- // Report initial height reliably - use multiple strategies
461
- function scheduleHeightReport() {
462
- // Immediate report
463
- reportContentHeight();
464
- // Delayed report to catch layout shifts
465
- setTimeout(reportContentHeight, HEIGHT_REPORT_DELAY_SHORT);
466
- setTimeout(reportContentHeight, HEIGHT_REPORT_DELAY_LONG);
467
- }
468
-
469
- if (document.readyState === 'complete') {
470
- scheduleHeightReport();
471
- } else {
472
- window.addEventListener('load', scheduleHeightReport);
473
- }
474
-
475
- // Watch for content size changes with ResizeObserver
476
- if (typeof ResizeObserver !== 'undefined') {
477
- const resizeObserver = new ResizeObserver(function() {
478
- reportContentHeight();
479
- });
480
- resizeObserver.observe(document.body);
481
- }
482
- })();
483
- </script>
484
- `;
485
- }
@@ -1,170 +0,0 @@
1
- import { render, screen } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
3
- import { processHtml } from "./html-processor";
4
-
5
- describe("processHtml", () => {
6
- describe("basic HTML rendering", () => {
7
- it("renders simple HTML elements", () => {
8
- const result = processHtml("<p>Hello, world!</p>");
9
- render(result);
10
- expect(screen.getByText("Hello, world!")).toBeInTheDocument();
11
- });
12
-
13
- it("renders headings", () => {
14
- const result = processHtml("<h1>Title</h1><h2>Subtitle</h2>");
15
- render(result);
16
- expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
17
- "Title",
18
- );
19
- expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
20
- "Subtitle",
21
- );
22
- });
23
-
24
- it("renders links", () => {
25
- const result = processHtml('<a href="https://example.com">Link</a>');
26
- render(result);
27
- const link = screen.getByRole("link", { name: "Link" });
28
- expect(link).toHaveAttribute("href", "https://example.com");
29
- });
30
-
31
- it("renders lists", () => {
32
- const result = processHtml("<ul><li>Item 1</li><li>Item 2</li></ul>");
33
- render(result);
34
- expect(screen.getByText("Item 1")).toBeInTheDocument();
35
- expect(screen.getByText("Item 2")).toBeInTheDocument();
36
- });
37
- });
38
-
39
- describe("dangerous element stripping", () => {
40
- it("strips script tags and shows placeholder", () => {
41
- const result = processHtml('<script>alert("xss")</script>');
42
- render(result);
43
- expect(screen.getByText("<script> removed")).toBeInTheDocument();
44
- expect(screen.queryByText('alert("xss")')).not.toBeInTheDocument();
45
- });
46
-
47
- it("strips iframe tags and shows placeholder", () => {
48
- const result = processHtml('<iframe src="https://evil.com"></iframe>');
49
- render(result);
50
- expect(screen.getByText("<iframe> removed")).toBeInTheDocument();
51
- });
52
-
53
- it("strips object tags and shows placeholder", () => {
54
- const result = processHtml('<object data="malware.swf"></object>');
55
- render(result);
56
- expect(screen.getByText("<object> removed")).toBeInTheDocument();
57
- });
58
-
59
- it("strips embed tags and shows placeholder", () => {
60
- const result = processHtml('<embed src="malware.swf">');
61
- render(result);
62
- expect(screen.getByText("<embed> removed")).toBeInTheDocument();
63
- });
64
- });
65
-
66
- describe("dangerous attribute stripping", () => {
67
- it("strips onclick handlers", () => {
68
- const result = processHtml('<button onclick="alert(1)">Click</button>');
69
- render(result);
70
- const button = screen.getByRole("button", { name: "Click" });
71
- expect(button).not.toHaveAttribute("onclick");
72
- });
73
-
74
- it("strips onerror handlers", () => {
75
- const result = processHtml('<img src="x" onerror="alert(1)" alt="test">');
76
- render(result);
77
- const img = screen.getByRole("img", { name: "test" });
78
- expect(img).not.toHaveAttribute("onerror");
79
- });
80
-
81
- it("strips onload handlers", () => {
82
- const result = processHtml('<div onload="alert(1)">Content</div>');
83
- render(result);
84
- const div = screen.getByText("Content");
85
- expect(div).not.toHaveAttribute("onload");
86
- });
87
-
88
- it("strips onmouseover handlers", () => {
89
- const result = processHtml('<div onmouseover="alert(1)">Hover</div>');
90
- render(result);
91
- const div = screen.getByText("Hover");
92
- expect(div).not.toHaveAttribute("onmouseover");
93
- });
94
- });
95
-
96
- describe("dangerous URL neutralization", () => {
97
- it("neutralizes javascript: URLs in href", () => {
98
- const result = processHtml('<a href="javascript:alert(1)">Click</a>');
99
- render(result);
100
- const link = screen.getByRole("link", { name: "Click" });
101
- expect(link).toHaveAttribute("href", "#");
102
- });
103
-
104
- it("neutralizes javascript: URLs in src", () => {
105
- const result = processHtml(
106
- '<img src="javascript:alert(1)" alt="bad img">',
107
- );
108
- render(result);
109
- const img = screen.getByRole("img", { name: "bad img" });
110
- expect(img).toHaveAttribute("src", "#");
111
- });
112
-
113
- it("neutralizes data: URLs", () => {
114
- const result = processHtml(
115
- '<a href="data:text/html,<script>alert(1)</script>">Click</a>',
116
- );
117
- render(result);
118
- const link = screen.getByRole("link", { name: "Click" });
119
- expect(link).toHaveAttribute("href", "#");
120
- });
121
-
122
- it("neutralizes vbscript: URLs", () => {
123
- const result = processHtml('<a href="vbscript:msgbox(1)">Click</a>');
124
- render(result);
125
- const link = screen.getByRole("link", { name: "Click" });
126
- expect(link).toHaveAttribute("href", "#");
127
- });
128
-
129
- it("preserves safe URLs", () => {
130
- const result = processHtml('<a href="https://example.com">Safe</a>');
131
- render(result);
132
- const link = screen.getByRole("link", { name: "Safe" });
133
- expect(link).toHaveAttribute("href", "https://example.com");
134
- });
135
- });
136
-
137
- describe("mixed content", () => {
138
- it("handles complex HTML with multiple dangerous elements", () => {
139
- const html = `
140
- <div>
141
- <h1>Title</h1>
142
- <script>alert("xss")</script>
143
- <p onclick="steal()">Paragraph with handler</p>
144
- <a href="javascript:void(0)">Bad link</a>
145
- <a href="https://safe.com">Safe link</a>
146
- <iframe src="evil.com"></iframe>
147
- </div>
148
- `;
149
- render(processHtml(html));
150
-
151
- // Script tag replaced with placeholder
152
- expect(screen.getByText("<script> removed")).toBeInTheDocument();
153
-
154
- // Event handler stripped
155
- const paragraph = screen.getByText("Paragraph with handler");
156
- expect(paragraph).not.toHaveAttribute("onclick");
157
-
158
- // Dangerous URL neutralized
159
- const badLink = screen.getByRole("link", { name: "Bad link" });
160
- expect(badLink).toHaveAttribute("href", "#");
161
-
162
- // Safe URL preserved
163
- const safeLink = screen.getByRole("link", { name: "Safe link" });
164
- expect(safeLink).toHaveAttribute("href", "https://safe.com");
165
-
166
- // Iframe tag replaced with placeholder
167
- expect(screen.getByText("<iframe> removed")).toBeInTheDocument();
168
- });
169
- });
170
- });