@djangocfg/ui-tools 2.1.407 → 2.1.409
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -10
- package/dist/file-icon/index.cjs +449 -61
- package/dist/file-icon/index.cjs.map +1 -1
- package/dist/file-icon/index.d.cts +56 -18
- package/dist/file-icon/index.d.ts +56 -18
- package/dist/file-icon/index.mjs +448 -62
- package/dist/file-icon/index.mjs.map +1 -1
- package/dist/tree/index.cjs +49 -22
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +9 -3
- package/dist/tree/index.d.ts +9 -3
- package/dist/tree/index.mjs +49 -22
- package/dist/tree/index.mjs.map +1 -1
- package/dist/{types-B_zhyAqR.d.cts → types-eEu8SeiQ.d.cts} +4 -0
- package/dist/{types-B_zhyAqR.d.ts → types-eEu8SeiQ.d.ts} +4 -0
- package/package.json +8 -13
- package/src/components/FloatingToolbar/index.tsx +37 -3
- package/src/lib/page-snapshot/__tests__/capture-integration.test.ts +85 -0
- package/src/lib/page-snapshot/__tests__/engine.test.ts +36 -0
- package/src/lib/page-snapshot/__tests__/redaction-integration.test.ts +99 -0
- package/src/lib/page-snapshot/__tests__/tokens.test.ts +17 -0
- package/src/lib/page-snapshot/capture/__tests__/budget.test.ts +49 -0
- package/src/lib/page-snapshot/capture/__tests__/chrome-filter.test.ts +47 -0
- package/src/lib/page-snapshot/capture/__tests__/fold.test.ts +66 -0
- package/src/lib/page-snapshot/capture/__tests__/scope.test.ts +74 -0
- package/src/lib/page-snapshot/capture/__tests__/walk.test.ts +129 -0
- package/src/lib/page-snapshot/capture/accessible-name.ts +73 -0
- package/src/lib/page-snapshot/capture/budget.ts +95 -0
- package/src/lib/page-snapshot/capture/chrome-filter.ts +81 -0
- package/src/lib/page-snapshot/capture/classify.ts +111 -0
- package/src/lib/page-snapshot/capture/dom-utils.ts +111 -0
- package/src/lib/page-snapshot/capture/fold.ts +96 -0
- package/src/lib/page-snapshot/capture/scope.ts +169 -0
- package/src/lib/page-snapshot/capture/walk.ts +250 -0
- package/src/lib/page-snapshot/cst/__tests__/serialize.test.ts +50 -0
- package/src/lib/page-snapshot/cst/directives.ts +47 -0
- package/src/lib/page-snapshot/cst/payload.ts +50 -0
- package/src/lib/page-snapshot/cst/serialize.ts +84 -0
- package/src/lib/page-snapshot/cst/types.ts +115 -0
- package/src/lib/page-snapshot/engine.ts +176 -0
- package/src/lib/page-snapshot/index.ts +93 -0
- package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +72 -0
- package/src/lib/page-snapshot/react/PageSnapshotPreview.tsx +78 -0
- package/src/lib/page-snapshot/react/__tests__/PageSnapshotChip.test.tsx +54 -0
- package/src/lib/page-snapshot/react/__tests__/provider.test.tsx +103 -0
- package/src/lib/page-snapshot/react/__tests__/use-page-snapshot-toggle.test.tsx +62 -0
- package/src/lib/page-snapshot/react/provider.tsx +162 -0
- package/src/lib/page-snapshot/react/use-page-snapshot-toggle.ts +47 -0
- package/src/lib/page-snapshot/react/use-page-snapshot.ts +67 -0
- package/src/lib/page-snapshot/redaction/__tests__/audit.test.ts +25 -0
- package/src/lib/page-snapshot/redaction/__tests__/heuristics.test.ts +73 -0
- package/src/lib/page-snapshot/redaction/__tests__/luhn.test.ts +26 -0
- package/src/lib/page-snapshot/redaction/__tests__/patterns.test.ts +60 -0
- package/src/lib/page-snapshot/redaction/audit.ts +58 -0
- package/src/lib/page-snapshot/redaction/heuristics.ts +75 -0
- package/src/lib/page-snapshot/redaction/index.ts +75 -0
- package/src/lib/page-snapshot/redaction/luhn.ts +25 -0
- package/src/lib/page-snapshot/redaction/patterns.ts +111 -0
- package/src/lib/page-snapshot/refs/__tests__/registry.test.ts +24 -0
- package/src/lib/page-snapshot/refs/registry.ts +46 -0
- package/src/lib/page-snapshot/staleness/__tests__/hash.test.ts +34 -0
- package/src/lib/page-snapshot/staleness/hash.ts +20 -0
- package/src/lib/page-snapshot/tokens.ts +15 -0
- package/src/tools/AudioPlayer/context/PlayerProvider.tsx +13 -14
- package/src/tools/AudioPlayer/hooks/useAudioElementEvents.ts +55 -6
- package/src/tools/AudioPlayer/parts/Meta/TimeDisplay.tsx +2 -5
- package/src/tools/Chat/README.md +277 -39
- package/src/tools/Chat/composer/Composer.tsx +471 -0
- package/src/tools/Chat/composer/ComposerActionBar.tsx +65 -0
- package/src/tools/Chat/composer/ComposerBanner.tsx +128 -0
- package/src/tools/Chat/composer/ComposerButton.tsx +64 -0
- package/src/tools/Chat/composer/ComposerFooter.tsx +90 -0
- package/src/tools/Chat/composer/ComposerMenuButton.tsx +62 -0
- package/src/tools/Chat/composer/ComposerModelPicker.tsx +104 -0
- package/src/tools/Chat/composer/ComposerRichTextarea.tsx +88 -0
- package/src/tools/Chat/composer/ComposerToolPill.tsx +95 -0
- package/src/tools/Chat/composer/index.ts +45 -0
- package/src/tools/Chat/composer/size-context.tsx +26 -0
- package/src/tools/Chat/composer/types.ts +143 -0
- package/src/tools/Chat/composer/useComposerActions.tsx +164 -0
- package/src/tools/Chat/context/ChatProvider.tsx +54 -3
- package/src/tools/Chat/core/__tests__/metadata.test.ts +69 -0
- package/src/tools/Chat/core/index.ts +23 -1
- package/src/tools/Chat/core/markdown.ts +1 -1
- package/src/tools/Chat/core/metadata.ts +47 -0
- package/src/tools/Chat/core/payload-dispatch.ts +1 -1
- package/src/tools/Chat/core/transport/http.ts +71 -32
- package/src/tools/Chat/core/transport/sse.ts +18 -10
- package/src/tools/Chat/highlight/HighlightOverlay.tsx +101 -0
- package/src/tools/Chat/highlight/README.md +103 -0
- package/src/tools/Chat/highlight/SpotlightCanvas.tsx +153 -0
- package/src/tools/Chat/highlight/__tests__/HighlightOverlay.test.tsx +112 -0
- package/src/tools/Chat/highlight/__tests__/resolveRef.test.ts +55 -0
- package/src/tools/Chat/highlight/index.ts +21 -0
- package/src/tools/Chat/highlight/resolveRef.ts +42 -0
- package/src/tools/Chat/highlight/types.ts +49 -0
- package/src/tools/Chat/highlight/useHighlightTargets.ts +128 -0
- package/src/tools/Chat/hooks/index.ts +0 -5
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +28 -47
- package/src/tools/Chat/hooks/useChat.ts +47 -14
- package/src/tools/Chat/hooks/useChatComposer.ts +2 -2
- package/src/tools/Chat/hooks/useChatLayout.ts +1 -1
- package/src/tools/Chat/hooks/useStreamEndFocus.ts +54 -0
- package/src/tools/Chat/index.ts +25 -219
- package/src/tools/Chat/launcher/ChatDock.tsx +1 -1
- package/src/tools/Chat/launcher/ChatLauncher.tsx +1 -1
- package/src/tools/Chat/launcher/{ChatHeader.tsx → header/ChatHeader.tsx} +24 -11
- package/src/tools/Chat/launcher/{ChatHeaderActionButton.tsx → header/ChatHeaderActionButton.tsx} +34 -3
- package/src/tools/Chat/launcher/{ChatHeaderLanguageButton.tsx → header/ChatHeaderLanguageButton.tsx} +2 -2
- package/src/tools/Chat/launcher/{ChatHeaderModeToggle.tsx → header/ChatHeaderModeToggle.tsx} +1 -1
- package/src/tools/Chat/launcher/{ChatHeaderResetButton.tsx → header/ChatHeaderResetButton.tsx} +2 -1
- package/src/tools/Chat/launcher/{HeaderSlots.tsx → header/HeaderSlots.tsx} +3 -3
- package/src/tools/Chat/launcher/header/index.ts +26 -0
- package/src/tools/Chat/launcher/index.ts +3 -10
- package/src/tools/Chat/lazy.tsx +38 -284
- package/src/tools/Chat/{components → messages}/MessageBubble.tsx +58 -5
- package/src/tools/Chat/{components → messages}/MessageList.tsx +8 -25
- package/src/tools/Chat/messages/blocks/MessageBlocks.tsx +131 -0
- package/src/tools/Chat/messages/blocks/builtin.tsx +91 -0
- package/src/tools/Chat/messages/blocks/index.ts +12 -0
- package/src/tools/Chat/messages/blocks/registry.tsx +42 -0
- package/src/tools/Chat/messages/blocks/renderers/AudioBlock.tsx +20 -0
- package/src/tools/Chat/messages/blocks/renderers/CodeBlock.tsx +19 -0
- package/src/tools/Chat/messages/blocks/renderers/GalleryBlock.tsx +26 -0
- package/src/tools/Chat/messages/blocks/renderers/ImageBlock.tsx +27 -0
- package/src/tools/Chat/messages/blocks/renderers/JsonBlock.tsx +12 -0
- package/src/tools/Chat/messages/blocks/renderers/LottieBlock.tsx +11 -0
- package/src/tools/Chat/messages/blocks/renderers/MapBlock.tsx +36 -0
- package/src/tools/Chat/messages/blocks/renderers/MermaidBlock.tsx +11 -0
- package/src/tools/Chat/messages/blocks/renderers/VideoBlock.tsx +24 -0
- package/src/tools/Chat/messages/blocks/renderers/types.ts +8 -0
- package/src/tools/Chat/{components → messages}/index.ts +11 -5
- package/src/tools/Chat/public.ts +212 -0
- package/src/tools/Chat/shell/ChatRoot.tsx +345 -0
- package/src/tools/Chat/{components → shell}/EmptyState.tsx +4 -2
- package/src/tools/Chat/shell/index.ts +15 -0
- package/src/tools/Chat/types/block.ts +120 -0
- package/src/tools/Chat/types/config.ts +0 -5
- package/src/tools/Chat/types/index.ts +17 -0
- package/src/tools/Chat/types/message.ts +3 -0
- package/src/tools/Chat/utils/index.ts +4 -0
- package/src/tools/CodeEditor/README.md +4 -6
- package/src/tools/CodeEditor/components/DiffEditor.tsx +48 -13
- package/src/tools/CodeEditor/components/Editor.tsx +96 -44
- package/src/tools/CodeEditor/context/EditorProvider.tsx +34 -17
- package/src/tools/CodeEditor/hooks/useEditorTheme.ts +92 -99
- package/src/tools/CodeEditor/hooks/useMonaco.ts +37 -22
- package/src/tools/CodeEditor/lazy.tsx +6 -0
- package/src/tools/CodeEditor/lib/index.ts +1 -1
- package/src/tools/CodeEditor/lib/themes.ts +3 -39
- package/src/tools/CronScheduler/CronScheduler.client.tsx +230 -61
- package/src/tools/CronScheduler/components/CustomInput.tsx +21 -4
- package/src/tools/CronScheduler/components/DayChips.tsx +13 -11
- package/src/tools/CronScheduler/components/MonthDayGrid.tsx +4 -4
- package/src/tools/CronScheduler/components/SchedulePreview.tsx +7 -3
- package/src/tools/CronScheduler/components/TimeSelector.tsx +1 -1
- package/src/tools/CronScheduler/index.tsx +1 -1
- package/src/tools/CronScheduler/types/index.ts +8 -3
- package/src/tools/CronScheduler/utils/cron-humanize.ts +61 -16
- package/src/tools/CronScheduler/utils/cron-parser.ts +13 -4
- package/src/tools/FileIcon/FileIcon.tsx +24 -39
- package/src/tools/FileIcon/get-file-icon.ts +73 -0
- package/src/tools/FileIcon/icons/icon-data.ts +399 -0
- package/src/tools/FileIcon/index.ts +4 -0
- package/src/tools/FileIcon/loader.ts +17 -35
- package/src/tools/FileIcon/specialFolders.ts +18 -0
- package/src/tools/Gallery/components/lightbox/GalleryLightbox.tsx +112 -35
- package/src/tools/Gallery/components/media/GalleryVideo.tsx +21 -2
- package/src/tools/Gallery/components/preview/GalleryCarousel.tsx +11 -1
- package/src/tools/Gallery/hooks/usePreloadImages.ts +54 -7
- package/src/tools/ImageViewer/components/ImageInfo.tsx +12 -1
- package/src/tools/ImageViewer/components/ImageToolbar.tsx +51 -43
- package/src/tools/ImageViewer/components/ImageViewer.tsx +96 -24
- package/src/tools/ImageViewer/hooks/useImageLoading.ts +13 -0
- package/src/tools/ImageViewer/utils/constants.ts +3 -0
- package/src/tools/ImageViewer/utils/index.ts +1 -0
- package/src/tools/JsonForm/JsonSchemaForm.tsx +4 -1
- package/src/tools/JsonForm/templates/ArrayFieldTemplate.tsx +5 -3
- package/src/tools/JsonForm/templates/BaseInputTemplate.tsx +7 -4
- package/src/tools/JsonForm/templates/ErrorListTemplate.tsx +3 -1
- package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +23 -3
- package/src/tools/JsonForm/widgets/ColorWidget.tsx +20 -12
- package/src/tools/JsonForm/widgets/NumberWidget.tsx +14 -9
- package/src/tools/JsonForm/widgets/RadioWidget.tsx +78 -0
- package/src/tools/JsonForm/widgets/SelectWidget.tsx +1 -0
- package/src/tools/JsonForm/widgets/SliderWidget.tsx +7 -4
- package/src/tools/JsonForm/widgets/TextWidget.tsx +41 -17
- package/src/tools/JsonForm/widgets/index.ts +1 -0
- package/src/tools/JsonTree/components/JsonContent.tsx +115 -40
- package/src/tools/LottiePlayer/LottiePlayer.client.tsx +177 -72
- package/src/tools/LottiePlayer/index.tsx +14 -4
- package/src/tools/LottiePlayer/lazy.tsx +11 -3
- package/src/tools/LottiePlayer/types.ts +31 -1
- package/src/tools/LottiePlayer/useLottie.ts +32 -9
- package/src/tools/LottiePlayer/usePrefersReducedMotion.ts +46 -0
- package/src/tools/Map/components/LayerSwitcher.tsx +54 -21
- package/src/tools/Map/components/MapCluster.tsx +28 -21
- package/src/tools/Map/components/MapContainer.tsx +11 -4
- package/src/tools/Map/components/MapLegend.tsx +46 -15
- package/src/tools/Map/components/MapMarker.tsx +31 -2
- package/src/tools/Map/hooks/useMapEvents.ts +64 -105
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +61 -6
- package/src/tools/MarkdownEditor/MentionList.tsx +37 -4
- package/src/tools/MarkdownEditor/createMentionSuggestion.ts +11 -0
- package/src/tools/MarkdownEditor/lazy.tsx +32 -7
- package/src/tools/MarkdownEditor/styles.css +13 -0
- package/src/tools/MarkdownMessage/CodeBlock.tsx +40 -17
- package/src/tools/MarkdownMessage/MarkdownMessage.tsx +26 -6
- package/src/tools/MarkdownMessage/components.tsx +22 -9
- package/src/tools/MarkdownMessage/types.ts +24 -1
- package/src/tools/Mermaid/Mermaid.client.tsx +27 -5
- package/src/tools/Mermaid/components/MermaidErrorPanel.tsx +31 -0
- package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +14 -17
- package/src/tools/Mermaid/hooks/useMermaidRenderer.ts +264 -168
- package/src/tools/Mermaid/hooks/useMermaidValidation.ts +76 -10
- package/src/tools/Mermaid/index.tsx +6 -0
- package/src/tools/Mermaid/utils/mermaid-helpers.ts +141 -18
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/FieldRow.tsx +11 -1
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/buildTree.ts +49 -20
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/index.tsx +7 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +7 -4
- package/src/tools/OpenapiViewer/constants.ts +3 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +73 -11
- package/src/tools/OpenapiViewer/utils/schemaExport.ts +26 -6
- package/src/tools/PrettyCode/PrettyCode.client.tsx +23 -16
- package/src/tools/PrettyCode/lazy.tsx +1 -1
- package/src/tools/SpeechRecognition/README.md +1 -1
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +9 -3
- package/src/tools/SpeechRecognition/components/RecordingPulse.tsx +59 -0
- package/src/tools/SpeechRecognition/components/index.ts +2 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +24 -7
- package/src/tools/SpeechRecognition/core/language.ts +23 -6
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +36 -5
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +18 -11
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +94 -26
- package/src/tools/SpeechRecognition/widgets/index.ts +1 -1
- package/src/tools/Tree/README.md +4 -8
- package/src/tools/Tree/TreeRoot.tsx +22 -10
- package/src/tools/Tree/components/TreeContent.tsx +24 -4
- package/src/tools/Tree/components/TreeLabel.tsx +8 -2
- package/src/tools/Tree/components/TreeRow.tsx +16 -6
- package/src/tools/Tree/data/flatten.ts +10 -4
- package/src/tools/Tree/types.ts +4 -0
- package/src/tools/Uploader/components/UploadAddButton.tsx +29 -6
- package/src/tools/Uploader/components/UploadDropzone.tsx +63 -7
- package/src/tools/Uploader/components/UploadPageDropOverlay.tsx +19 -5
- package/src/tools/Uploader/components/UploadPreviewItem.tsx +47 -17
- package/src/tools/Uploader/components/UploadPreviewList.tsx +24 -12
- package/src/tools/Uploader/utils/formatters.ts +8 -3
- package/src/tools/VideoPlayer/canvas/hls-canvas.tsx +1 -0
- package/src/tools/VideoPlayer/canvas/{jsx.d.ts → jsx-augmentation.ts} +12 -19
- package/src/tools/VideoPlayer/canvas/vimeo-canvas.tsx +1 -0
- package/src/tools/VideoPlayer/canvas/youtube-canvas.tsx +1 -0
- package/src/tools/VideoPlayer/parts/fullscreen.tsx +1 -1
- package/src/tools/VideoPlayer/parts/pip.tsx +1 -1
- package/src/tools/VideoPlayer/parts/playback-rate.tsx +1 -1
- package/src/tools/VideoPlayer/parts/seek-bar.tsx +2 -2
- package/src/tools/VideoPlayer/parts/volume.tsx +2 -2
- package/src/tools/index.ts +2 -1
- package/src/tools/Chat/components/AudioToggle.tsx +0 -78
- package/src/tools/Chat/components/ChatRoot.tsx +0 -305
- package/src/tools/Chat/components/Composer.tsx +0 -216
- package/src/tools/Chat/hooks/useChatScroll.ts +0 -145
- package/src/tools/Chat/types.ts +0 -9
- package/src/tools/JsonTree/components/JsonToolbar.tsx +0 -95
- package/src/tools/JsonTree/hooks/useElementCorner.ts +0 -84
- package/src/tools/JsonTree/hooks/useNavbarHeight.ts +0 -83
- package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +0 -121
- package/src/tools/Tour/README.md +0 -373
- package/src/tools/Tour/components/Tour.tsx +0 -12
- package/src/tools/Tour/components/TourContent.tsx +0 -171
- package/src/tools/Tour/components/TourNavigation.tsx +0 -77
- package/src/tools/Tour/components/TourProgress.tsx +0 -88
- package/src/tools/Tour/components/TourSpotlight.tsx +0 -199
- package/src/tools/Tour/components/index.ts +0 -5
- package/src/tools/Tour/context/TourContext.ts +0 -19
- package/src/tools/Tour/context/TourProvider.tsx +0 -292
- package/src/tools/Tour/context/index.ts +0 -2
- package/src/tools/Tour/hooks/index.ts +0 -3
- package/src/tools/Tour/hooks/useKeyboardNavigation.ts +0 -59
- package/src/tools/Tour/hooks/useStepTarget.ts +0 -121
- package/src/tools/Tour/hooks/useTour.ts +0 -42
- package/src/tools/Tour/index.ts +0 -38
- package/src/tools/Tour/types/index.ts +0 -224
- package/src/tools/Tour/utils/dom.ts +0 -98
- package/src/tools/Tour/utils/index.ts +0 -3
- package/src/tools/Tour/utils/logger.ts +0 -3
- package/src/tools/Tour/utils/scrollIntoView.ts +0 -24
- /package/src/tools/Chat/{config.ts → constants.ts} +0 -0
- /package/src/tools/Chat/launcher/{ChatHeaderAudioToggle.tsx → header/ChatHeaderAudioToggle.tsx} +0 -0
- /package/src/tools/Chat/{components → messages}/Attachments.tsx +0 -0
- /package/src/tools/Chat/{components → messages}/JumpToLatest.tsx +0 -0
- /package/src/tools/Chat/{components → messages}/MessageActions.tsx +0 -0
- /package/src/tools/Chat/{components → messages}/Sources.tsx +0 -0
- /package/src/tools/Chat/{components → messages}/StreamingIndicator.tsx +0 -0
- /package/src/tools/Chat/{components → messages}/ToolCalls.tsx +0 -0
- /package/src/tools/Chat/{components → shell}/ErrorBanner.tsx +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { walkDOM } from '../walk';
|
|
5
|
+
import type { CSTInteractiveNode } from '../../cst/types';
|
|
6
|
+
import { isContainerNode, isInteractiveNode } from '../../cst/types';
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
document.body.innerHTML = '';
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/** Flatten a CST subtree to all interactive nodes. */
|
|
13
|
+
function allInteractive(root: HTMLElement) {
|
|
14
|
+
const { children } = walkDOM(root);
|
|
15
|
+
const out: CSTInteractiveNode[] = [];
|
|
16
|
+
const visit = (nodes: ReturnType<typeof walkDOM>['children']) => {
|
|
17
|
+
for (const n of nodes) {
|
|
18
|
+
if (isInteractiveNode(n)) out.push(n);
|
|
19
|
+
else if (isContainerNode(n) || n.type === 'root') visit(n.children);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
visit(children);
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('walkDOM', () => {
|
|
27
|
+
it('captures interactive elements with refs', () => {
|
|
28
|
+
document.body.innerHTML = `
|
|
29
|
+
<form>
|
|
30
|
+
<label for="c">Company</label>
|
|
31
|
+
<input id="c" type="text" value="Acme" />
|
|
32
|
+
<button type="submit">Save</button>
|
|
33
|
+
</form>`;
|
|
34
|
+
const result = walkDOM(document.body);
|
|
35
|
+
|
|
36
|
+
const interactive = allInteractive(document.body);
|
|
37
|
+
expect(interactive).toHaveLength(2);
|
|
38
|
+
expect(interactive[0].ref).toBe('@e1');
|
|
39
|
+
expect(interactive[1].ref).toBe('@e2');
|
|
40
|
+
expect(result.refMap.size).toBe(2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('resolves accessible name from a <label for>', () => {
|
|
44
|
+
document.body.innerHTML = `
|
|
45
|
+
<label for="c">Company Name</label>
|
|
46
|
+
<input id="c" type="text" />`;
|
|
47
|
+
const [input] = allInteractive(document.body);
|
|
48
|
+
expect(input.name).toBe('Company Name');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('captures input value and role', () => {
|
|
52
|
+
document.body.innerHTML = `<input type="search" value="hello" />`;
|
|
53
|
+
const [input] = allInteractive(document.body);
|
|
54
|
+
expect(input.role).toBe('searchbox');
|
|
55
|
+
expect(input.value).toBe('hello');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('captures checkbox checked state, not value', () => {
|
|
59
|
+
document.body.innerHTML = `<input type="checkbox" checked />`;
|
|
60
|
+
const [cb] = allInteractive(document.body);
|
|
61
|
+
expect(cb.role).toBe('checkbox');
|
|
62
|
+
expect(cb.checked).toBe(true);
|
|
63
|
+
expect(cb.value).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('captures disabled and required state', () => {
|
|
67
|
+
document.body.innerHTML = `
|
|
68
|
+
<input type="text" disabled required />`;
|
|
69
|
+
const [input] = allInteractive(document.body);
|
|
70
|
+
expect(input.disabled).toBe(true);
|
|
71
|
+
expect(input.required).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('excludes chrome (nav) from the walk', () => {
|
|
75
|
+
document.body.innerHTML = `
|
|
76
|
+
<nav><a href="/x">Nav Link</a></nav>
|
|
77
|
+
<main><button>Real Button</button></main>`;
|
|
78
|
+
const interactive = allInteractive(document.body);
|
|
79
|
+
expect(interactive).toHaveLength(1);
|
|
80
|
+
expect(interactive[0].name).toBe('Real Button');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('skips script/style tags', () => {
|
|
84
|
+
document.body.innerHTML = `
|
|
85
|
+
<div>
|
|
86
|
+
<script>var x = 1;</script>
|
|
87
|
+
<style>.a{}</style>
|
|
88
|
+
<p>Visible text</p>
|
|
89
|
+
</div>`;
|
|
90
|
+
const { children } = walkDOM(document.body);
|
|
91
|
+
const json = JSON.stringify(children);
|
|
92
|
+
expect(json).not.toContain('var x');
|
|
93
|
+
expect(json).not.toContain('.a{}');
|
|
94
|
+
expect(json).toContain('Visible text');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('emits a placeholder for canvas/svg instead of descending', () => {
|
|
98
|
+
document.body.innerHTML = `
|
|
99
|
+
<canvas aria-label="Sales chart"></canvas>`;
|
|
100
|
+
const { children } = walkDOM(document.body);
|
|
101
|
+
expect(JSON.stringify(children)).toContain('[Sales chart]');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('applies the redaction hook to values and text', () => {
|
|
105
|
+
document.body.innerHTML = `
|
|
106
|
+
<input type="text" value="secret-value" />
|
|
107
|
+
<p>plain paragraph</p>`;
|
|
108
|
+
const { children } = walkDOM(document.body, {
|
|
109
|
+
redactValue: (v, ctx) => (ctx.kind === 'value' ? '‹redacted›' : v),
|
|
110
|
+
});
|
|
111
|
+
const json = JSON.stringify(children);
|
|
112
|
+
expect(json).toContain('‹redacted›');
|
|
113
|
+
expect(json).not.toContain('secret-value');
|
|
114
|
+
expect(json).toContain('plain paragraph');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('builds a container node for a form', () => {
|
|
118
|
+
document.body.innerHTML = `<form><button>Go</button></form>`;
|
|
119
|
+
const { children } = walkDOM(document.body);
|
|
120
|
+
const form = children.find(isContainerNode);
|
|
121
|
+
expect(form?.role).toBe('form');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('honors maxNodes as a safety valve', () => {
|
|
125
|
+
document.body.innerHTML = `<div><div><div><button>x</button></div></div></div>`;
|
|
126
|
+
const result = walkDOM(document.body, { maxNodes: 2 });
|
|
127
|
+
expect(result.nodesVisited).toBeLessThanOrEqual(2);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessible-name resolution.
|
|
3
|
+
*
|
|
4
|
+
* A pragmatic subset of the ARIA accessible-name algorithm — enough to
|
|
5
|
+
* give the AI a meaningful label for interactive elements. Adapted from
|
|
6
|
+
* cmdop_browser/perception/axtree.py.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { tagName } from './dom-utils';
|
|
10
|
+
|
|
11
|
+
/** Trim and collapse whitespace; cap length. */
|
|
12
|
+
function clean(text: string, max = 120): string {
|
|
13
|
+
const t = text.replace(/\s+/g, ' ').trim();
|
|
14
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Escape a string for use in a CSS selector (CSS.escape fallback). */
|
|
18
|
+
function escapeId(id: string): string {
|
|
19
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
20
|
+
return CSS.escape(id);
|
|
21
|
+
}
|
|
22
|
+
// Minimal fallback: escape characters not valid in an unescaped ident.
|
|
23
|
+
return id.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve the accessible name of an element. */
|
|
27
|
+
export function accessibleName(el: Element): string {
|
|
28
|
+
// aria-label wins.
|
|
29
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
30
|
+
if (ariaLabel) return clean(ariaLabel);
|
|
31
|
+
|
|
32
|
+
// aria-labelledby → referenced element text.
|
|
33
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
34
|
+
if (labelledBy) {
|
|
35
|
+
const parts = labelledBy
|
|
36
|
+
.split(/\s+/)
|
|
37
|
+
.map((id) => document.getElementById(id)?.textContent ?? '')
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
if (parts.length) return clean(parts.join(' '));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tag = tagName(el);
|
|
43
|
+
|
|
44
|
+
// Form controls — associated <label>.
|
|
45
|
+
if (tag === 'input' || tag === 'select' || tag === 'textarea') {
|
|
46
|
+
const id = el.getAttribute('id');
|
|
47
|
+
if (id) {
|
|
48
|
+
const label = document.querySelector(`label[for="${escapeId(id)}"]`);
|
|
49
|
+
if (label?.textContent) return clean(label.textContent);
|
|
50
|
+
}
|
|
51
|
+
const wrapping = el.closest('label');
|
|
52
|
+
if (wrapping?.textContent) return clean(wrapping.textContent);
|
|
53
|
+
const placeholder = el.getAttribute('placeholder');
|
|
54
|
+
if (placeholder) return clean(placeholder);
|
|
55
|
+
const name = el.getAttribute('name');
|
|
56
|
+
if (name) return clean(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Image — alt text.
|
|
60
|
+
if (tag === 'img') {
|
|
61
|
+
const alt = el.getAttribute('alt');
|
|
62
|
+
if (alt) return clean(alt);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// title attribute fallback.
|
|
66
|
+
const title = el.getAttribute('title');
|
|
67
|
+
if (title) return clean(title);
|
|
68
|
+
|
|
69
|
+
// Otherwise the element's own visible text (links, buttons).
|
|
70
|
+
if (el.textContent) return clean(el.textContent);
|
|
71
|
+
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-budget enforcement — degradation ladder.
|
|
3
|
+
*
|
|
4
|
+
* Sibling folding (fold.ts) runs first and always. If the snapshot is
|
|
5
|
+
* still over budget, this applies progressive degradation:
|
|
6
|
+
* 1. truncate long text nodes
|
|
7
|
+
* 2. drop static text, keep only interactive skeleton
|
|
8
|
+
*
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CSTNode } from '../cst/types';
|
|
12
|
+
import { hasChildren, isTextNode } from '../cst/types';
|
|
13
|
+
import { serializeCST } from '../cst/serialize';
|
|
14
|
+
import { estimateTokens } from '../tokens';
|
|
15
|
+
|
|
16
|
+
/** Max characters a text node keeps under truncation. */
|
|
17
|
+
const TEXT_TRUNCATE_CHARS = 150;
|
|
18
|
+
|
|
19
|
+
/** What degradation, if any, was applied. */
|
|
20
|
+
export type DegradationLevel = 'none' | 'truncated' | 'interactive-only';
|
|
21
|
+
|
|
22
|
+
/** Result of budget enforcement. */
|
|
23
|
+
export interface BudgetResult {
|
|
24
|
+
children: CSTNode[];
|
|
25
|
+
tokenEstimate: number;
|
|
26
|
+
degradation: DegradationLevel;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Estimate the token cost of a node list. */
|
|
30
|
+
function costOf(children: CSTNode[]): number {
|
|
31
|
+
let total = 0;
|
|
32
|
+
for (const node of children) total += estimateTokens(serializeCST(node));
|
|
33
|
+
return total;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Truncate long text nodes recursively. */
|
|
37
|
+
function truncateText(node: CSTNode): CSTNode {
|
|
38
|
+
if (isTextNode(node)) {
|
|
39
|
+
if (node.content.length <= TEXT_TRUNCATE_CHARS) return node;
|
|
40
|
+
return {
|
|
41
|
+
type: 'text',
|
|
42
|
+
content: `${node.content.slice(0, TEXT_TRUNCATE_CHARS)}… (truncated)`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (hasChildren(node)) {
|
|
46
|
+
return { ...node, children: node.children.map(truncateText) };
|
|
47
|
+
}
|
|
48
|
+
return node;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Keep only interactive nodes and the containers leading to them. */
|
|
52
|
+
function pruneToInteractive(node: CSTNode): CSTNode | null {
|
|
53
|
+
if (node.type === 'interactive') return node;
|
|
54
|
+
if (node.type === 'text') return null;
|
|
55
|
+
if (hasChildren(node)) {
|
|
56
|
+
const kept = node.children
|
|
57
|
+
.map(pruneToInteractive)
|
|
58
|
+
.filter((n): n is CSTNode => n !== null);
|
|
59
|
+
if (kept.length === 0) return null;
|
|
60
|
+
return { ...node, children: kept };
|
|
61
|
+
}
|
|
62
|
+
return node;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Enforce the token budget on a captured node list via the degradation
|
|
67
|
+
* ladder. Folding is assumed already applied (fold.ts).
|
|
68
|
+
*/
|
|
69
|
+
export function enforceBudget(
|
|
70
|
+
children: CSTNode[],
|
|
71
|
+
tokenBudget: number,
|
|
72
|
+
): BudgetResult {
|
|
73
|
+
let cost = costOf(children);
|
|
74
|
+
if (cost <= tokenBudget) {
|
|
75
|
+
return { children, tokenEstimate: cost, degradation: 'none' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Pass 1 — truncate long text.
|
|
79
|
+
const truncated = children.map(truncateText);
|
|
80
|
+
cost = costOf(truncated);
|
|
81
|
+
if (cost <= tokenBudget) {
|
|
82
|
+
return { children: truncated, tokenEstimate: cost, degradation: 'truncated' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Pass 2 — interactive-only skeleton.
|
|
86
|
+
const skeleton = truncated
|
|
87
|
+
.map(pruneToInteractive)
|
|
88
|
+
.filter((n): n is CSTNode => n !== null);
|
|
89
|
+
cost = costOf(skeleton);
|
|
90
|
+
return {
|
|
91
|
+
children: skeleton,
|
|
92
|
+
tokenEstimate: cost,
|
|
93
|
+
degradation: 'interactive-only',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout-chrome exclusion.
|
|
3
|
+
*
|
|
4
|
+
* Identifies global navigation / header / footer / sidebar — the
|
|
5
|
+
* "chrome" that surrounds real content. Runs inside every scope tier
|
|
6
|
+
* and again during the walk, so the snapshot never wastes tokens on
|
|
7
|
+
* app shell the user is not asking about.
|
|
8
|
+
*
|
|
9
|
+
* Combines three signals: ARIA landmark roles, the
|
|
10
|
+
* Readability `unlikelyCandidates` class/id regex, and geometry
|
|
11
|
+
* (fixed-position bars).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { tagName } from './dom-utils';
|
|
15
|
+
|
|
16
|
+
/** Landmark roles / tags that are chrome by definition. */
|
|
17
|
+
const CHROME_ROLES: ReadonlySet<string> = new Set([
|
|
18
|
+
'navigation',
|
|
19
|
+
'banner',
|
|
20
|
+
'contentinfo',
|
|
21
|
+
'complementary',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const CHROME_TAGS: ReadonlySet<string> = new Set([
|
|
25
|
+
'nav',
|
|
26
|
+
'header',
|
|
27
|
+
'footer',
|
|
28
|
+
'aside',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Readability's `unlikelyCandidates` regex — class/id tokens that mark
|
|
33
|
+
* an element as non-content. Trimmed to the app-UI-relevant subset.
|
|
34
|
+
*/
|
|
35
|
+
const UNLIKELY_RE =
|
|
36
|
+
/(^|[\s_-])(nav|navbar|sidebar|side-?bar|menu|header|footer|banner|breadcrumb|toolbar|topbar|appbar|masthead|drawer|skip-?link)([\s_-]|$)/i;
|
|
37
|
+
|
|
38
|
+
/** Roles that explicitly mark content — never treat these as chrome. */
|
|
39
|
+
const CONTENT_RE = /(^|[\s_-])(main|content|article|workspace)([\s_-]|$)/i;
|
|
40
|
+
|
|
41
|
+
/** Does the element's role / tag make it a landmark-level chrome region. */
|
|
42
|
+
function isLandmarkChrome(el: Element): boolean {
|
|
43
|
+
const role = el.getAttribute('role')?.toLowerCase();
|
|
44
|
+
if (role && CHROME_ROLES.has(role)) return true;
|
|
45
|
+
if (CHROME_TAGS.has(tagName(el))) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Does the class/id name pattern mark the element as chrome. */
|
|
50
|
+
function isNamedChrome(el: Element): boolean {
|
|
51
|
+
const tokens = `${el.className ?? ''} ${el.id ?? ''}`;
|
|
52
|
+
if (!tokens.trim()) return false;
|
|
53
|
+
if (CONTENT_RE.test(tokens)) return false;
|
|
54
|
+
return UNLIKELY_RE.test(tokens);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Is the element a fixed/sticky global bar (geometry signal). */
|
|
58
|
+
function isFixedBar(el: Element): boolean {
|
|
59
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
60
|
+
const pos = getComputedStyle(el).position;
|
|
61
|
+
if (pos !== 'fixed' && pos !== 'sticky') return false;
|
|
62
|
+
// A fixed bar is typically thin relative to the viewport.
|
|
63
|
+
const rect = el.getBoundingClientRect();
|
|
64
|
+
const thin =
|
|
65
|
+
rect.height < window.innerHeight * 0.25 ||
|
|
66
|
+
rect.width < window.innerWidth * 0.25;
|
|
67
|
+
return thin;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Is this element layout chrome that should be excluded from capture.
|
|
72
|
+
*
|
|
73
|
+
* A content landmark (`<main>`, `[role=main]`, or a content-named
|
|
74
|
+
* element) is never chrome, even if nested oddly.
|
|
75
|
+
*/
|
|
76
|
+
export function isChrome(el: Element): boolean {
|
|
77
|
+
const role = el.getAttribute('role')?.toLowerCase();
|
|
78
|
+
if (role === 'main' || tagName(el) === 'main') return false;
|
|
79
|
+
|
|
80
|
+
return isLandmarkChrome(el) || isNamedChrome(el) || isFixedBar(el);
|
|
81
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Element classification — map a DOM element to a CST node kind & role.
|
|
3
|
+
*
|
|
4
|
+
* The role tables are adapted from cmdop_browser/perception/axtree.py
|
|
5
|
+
* (its `_AX_JS` role map).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CSTContainerRole, CSTInteractiveRole } from '../cst/types';
|
|
9
|
+
import { tagName } from './dom-utils';
|
|
10
|
+
|
|
11
|
+
/** How an element should be represented in the CST. */
|
|
12
|
+
export type NodeKind = 'interactive' | 'container' | 'text-host' | 'skip';
|
|
13
|
+
|
|
14
|
+
/** Tags that are always interactive. */
|
|
15
|
+
const INTERACTIVE_TAG_ROLE: Record<string, CSTInteractiveRole> = {
|
|
16
|
+
a: 'link',
|
|
17
|
+
button: 'button',
|
|
18
|
+
select: 'select',
|
|
19
|
+
textarea: 'textarea',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** `<input type>` → interactive role. */
|
|
23
|
+
const INPUT_TYPE_ROLE: Record<string, CSTInteractiveRole> = {
|
|
24
|
+
button: 'button',
|
|
25
|
+
submit: 'button',
|
|
26
|
+
reset: 'button',
|
|
27
|
+
checkbox: 'checkbox',
|
|
28
|
+
radio: 'radio',
|
|
29
|
+
range: 'slider',
|
|
30
|
+
number: 'spinbutton',
|
|
31
|
+
search: 'searchbox',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** ARIA roles we treat as interactive. */
|
|
35
|
+
const ARIA_INTERACTIVE: ReadonlySet<string> = new Set([
|
|
36
|
+
'button',
|
|
37
|
+
'link',
|
|
38
|
+
'textbox',
|
|
39
|
+
'checkbox',
|
|
40
|
+
'radio',
|
|
41
|
+
'combobox',
|
|
42
|
+
'switch',
|
|
43
|
+
'tab',
|
|
44
|
+
'menuitem',
|
|
45
|
+
'slider',
|
|
46
|
+
'spinbutton',
|
|
47
|
+
'searchbox',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Tag → container role.
|
|
52
|
+
*
|
|
53
|
+
* `tr` / `li` map to `section` so each row / list item stays its own
|
|
54
|
+
* node — this is what lets sibling folding collapse repetitive table
|
|
55
|
+
* rows and list items (see fold.ts). Without it the cells
|
|
56
|
+
* flatten into one undifferentiated list and folding cannot fire.
|
|
57
|
+
*/
|
|
58
|
+
const CONTAINER_TAG_ROLE: Record<string, CSTContainerRole> = {
|
|
59
|
+
form: 'form',
|
|
60
|
+
table: 'table',
|
|
61
|
+
thead: 'section',
|
|
62
|
+
tbody: 'section',
|
|
63
|
+
tr: 'section',
|
|
64
|
+
ul: 'list',
|
|
65
|
+
ol: 'list',
|
|
66
|
+
li: 'section',
|
|
67
|
+
nav: 'navigation',
|
|
68
|
+
section: 'section',
|
|
69
|
+
article: 'section',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** ARIA role → container role. */
|
|
73
|
+
const ARIA_CONTAINER: Record<string, CSTContainerRole> = {
|
|
74
|
+
form: 'form',
|
|
75
|
+
table: 'table',
|
|
76
|
+
grid: 'grid',
|
|
77
|
+
list: 'list',
|
|
78
|
+
navigation: 'navigation',
|
|
79
|
+
region: 'region',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Resolve the interactive role of an element, if it is interactive. */
|
|
83
|
+
export function interactiveRole(el: Element): CSTInteractiveRole | null {
|
|
84
|
+
const aria = el.getAttribute('role')?.toLowerCase();
|
|
85
|
+
if (aria && ARIA_INTERACTIVE.has(aria)) {
|
|
86
|
+
return aria as CSTInteractiveRole;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const tag = tagName(el);
|
|
90
|
+
if (tag === 'input') {
|
|
91
|
+
const type = (el.getAttribute('type') ?? 'text').toLowerCase();
|
|
92
|
+
return INPUT_TYPE_ROLE[type] ?? 'textbox';
|
|
93
|
+
}
|
|
94
|
+
if (el.getAttribute('contenteditable') === 'true') return 'textbox';
|
|
95
|
+
|
|
96
|
+
return INTERACTIVE_TAG_ROLE[tag] ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Resolve the container role of an element, if it is a container. */
|
|
100
|
+
export function containerRole(el: Element): CSTContainerRole | null {
|
|
101
|
+
const aria = el.getAttribute('role')?.toLowerCase();
|
|
102
|
+
if (aria && aria in ARIA_CONTAINER) return ARIA_CONTAINER[aria];
|
|
103
|
+
return CONTAINER_TAG_ROLE[tagName(el)] ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Classify an element into a CST node kind. */
|
|
107
|
+
export function classify(el: Element): NodeKind {
|
|
108
|
+
if (interactiveRole(el)) return 'interactive';
|
|
109
|
+
if (containerRole(el)) return 'container';
|
|
110
|
+
return 'text-host';
|
|
111
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared DOM helpers for capture — visibility, geometry, classification.
|
|
3
|
+
*
|
|
4
|
+
* All layout reads (`getBoundingClientRect`, `getComputedStyle`) live
|
|
5
|
+
* here so callers can keep them out of the recursive walk — interleaving
|
|
6
|
+
* layout reads with the walk would cause layout thrashing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Tags that never contribute to a snapshot. */
|
|
10
|
+
export const SKIP_TAGS: ReadonlySet<string> = new Set([
|
|
11
|
+
'script',
|
|
12
|
+
'style',
|
|
13
|
+
'noscript',
|
|
14
|
+
'meta',
|
|
15
|
+
'link',
|
|
16
|
+
'head',
|
|
17
|
+
'template',
|
|
18
|
+
'br',
|
|
19
|
+
'hr',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** Tags excluded but replaced with a placeholder (may carry text). */
|
|
23
|
+
export const PLACEHOLDER_TAGS: ReadonlySet<string> = new Set([
|
|
24
|
+
'canvas',
|
|
25
|
+
'svg',
|
|
26
|
+
'iframe',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tags whose zero geometry must not mark them invisible — leaf controls
|
|
31
|
+
* and media that legitimately have no text content, plus table
|
|
32
|
+
* structure with no own box. Visibility for these is style-only.
|
|
33
|
+
*/
|
|
34
|
+
const ZERO_BOX_OK: ReadonlySet<string> = new Set([
|
|
35
|
+
// Form controls — leaf, often no text content.
|
|
36
|
+
'input',
|
|
37
|
+
'select',
|
|
38
|
+
'textarea',
|
|
39
|
+
'button',
|
|
40
|
+
'option',
|
|
41
|
+
'optgroup',
|
|
42
|
+
// Media / embedded — captured as placeholders.
|
|
43
|
+
'canvas',
|
|
44
|
+
'svg',
|
|
45
|
+
'img',
|
|
46
|
+
'iframe',
|
|
47
|
+
// Table structure with no own box.
|
|
48
|
+
'tr',
|
|
49
|
+
'tbody',
|
|
50
|
+
'thead',
|
|
51
|
+
'tfoot',
|
|
52
|
+
'colgroup',
|
|
53
|
+
'col',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Is an element rendered — not display:none / visibility:hidden /
|
|
58
|
+
* collapsed to nothing.
|
|
59
|
+
*
|
|
60
|
+
* Visibility is decided primarily from computed *style* (reliable
|
|
61
|
+
* everywhere, including jsdom which does no layout). Geometry is only a
|
|
62
|
+
* secondary signal: a real zero-size box marks an element hidden, but
|
|
63
|
+
* an environment that reports zero geometry for everything (jsdom) must
|
|
64
|
+
* not blank the whole tree — so a zero box is only disqualifying when
|
|
65
|
+
* the element also has no text and no children.
|
|
66
|
+
*/
|
|
67
|
+
export function isVisible(el: Element): boolean {
|
|
68
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
69
|
+
const style = getComputedStyle(el);
|
|
70
|
+
if (style.display === 'none') return false;
|
|
71
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (style.opacity === '0') return false;
|
|
75
|
+
if (el.hasAttribute('hidden')) return false;
|
|
76
|
+
if (el.getAttribute('aria-hidden') === 'true') return false;
|
|
77
|
+
|
|
78
|
+
if (ZERO_BOX_OK.has(tagName(el))) return true;
|
|
79
|
+
|
|
80
|
+
const rect = el.getBoundingClientRect();
|
|
81
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
82
|
+
// A truly empty zero-box is hidden; but an element with content and
|
|
83
|
+
// a zero box is likely just an un-laid-out environment — keep it.
|
|
84
|
+
const hasContent =
|
|
85
|
+
el.children.length > 0 || (el.textContent ?? '').trim().length > 0;
|
|
86
|
+
return hasContent;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Rendered area of an element in CSS pixels. */
|
|
92
|
+
export function elementArea(el: Element): number {
|
|
93
|
+
const rect = el.getBoundingClientRect();
|
|
94
|
+
return Math.max(0, rect.width) * Math.max(0, rect.height);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Ratio of link-text length to total text length in a subtree (0–1). */
|
|
98
|
+
export function linkDensity(el: Element): number {
|
|
99
|
+
const total = (el.textContent ?? '').trim().length;
|
|
100
|
+
if (total === 0) return 0;
|
|
101
|
+
let linkChars = 0;
|
|
102
|
+
el.querySelectorAll('a').forEach((a) => {
|
|
103
|
+
linkChars += (a.textContent ?? '').trim().length;
|
|
104
|
+
});
|
|
105
|
+
return Math.min(1, linkChars / total);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** lowercased tag name. */
|
|
109
|
+
export function tagName(el: Element): string {
|
|
110
|
+
return el.tagName.toLowerCase();
|
|
111
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sibling folding — collapse repetitive structure.
|
|
3
|
+
*
|
|
4
|
+
* Always-on, applied to every node list. Structurally-identical sibling
|
|
5
|
+
* chains (table rows, list items, grid cells) collapse to "first N +
|
|
6
|
+
* a collapsed marker". This is what makes a 100-row data table fit the
|
|
7
|
+
* token budget — without it a 100-row table snapshot is ~10x over.
|
|
8
|
+
*
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CSTNode } from '../cst/types';
|
|
12
|
+
import { hasChildren } from '../cst/types';
|
|
13
|
+
|
|
14
|
+
/** How many leading instances of a repeated chain to keep. */
|
|
15
|
+
const KEEP_LEADING = 3;
|
|
16
|
+
/** A chain shorter than this is never folded. */
|
|
17
|
+
const MIN_CHAIN = 5;
|
|
18
|
+
|
|
19
|
+
/** Result of a folding pass. */
|
|
20
|
+
export interface FoldResult {
|
|
21
|
+
/** The folded tree. */
|
|
22
|
+
children: CSTNode[];
|
|
23
|
+
/** How many sibling chains were folded. */
|
|
24
|
+
foldedCount: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A shallow structural hash of a node — its shape, not its content.
|
|
29
|
+
* Two nodes with the same hash are "the same kind of thing" (e.g. two
|
|
30
|
+
* table rows), regardless of the text they hold.
|
|
31
|
+
*/
|
|
32
|
+
function structuralHash(node: CSTNode): string {
|
|
33
|
+
switch (node.type) {
|
|
34
|
+
case 'text':
|
|
35
|
+
return 't';
|
|
36
|
+
case 'interactive':
|
|
37
|
+
return `i:${node.role}`;
|
|
38
|
+
case 'container':
|
|
39
|
+
return `c:${node.role}:[${node.children
|
|
40
|
+
.map(structuralHash)
|
|
41
|
+
.join(',')}]`;
|
|
42
|
+
case 'root':
|
|
43
|
+
return 'root';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Counter threaded through a recursive fold so totals aggregate. */
|
|
48
|
+
interface FoldCtx {
|
|
49
|
+
folded: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Fold one list of sibling nodes. */
|
|
53
|
+
function foldList(nodes: CSTNode[], ctx: FoldCtx): CSTNode[] {
|
|
54
|
+
// Recurse first so nested lists are folded before the outer pass.
|
|
55
|
+
const deep = nodes.map((n) => foldNode(n, ctx));
|
|
56
|
+
|
|
57
|
+
const out: CSTNode[] = [];
|
|
58
|
+
let i = 0;
|
|
59
|
+
while (i < deep.length) {
|
|
60
|
+
const hash = structuralHash(deep[i]);
|
|
61
|
+
// Extend the run of identically-shaped siblings.
|
|
62
|
+
let j = i + 1;
|
|
63
|
+
while (j < deep.length && structuralHash(deep[j]) === hash) j++;
|
|
64
|
+
const runLength = j - i;
|
|
65
|
+
|
|
66
|
+
if (runLength >= MIN_CHAIN) {
|
|
67
|
+
// Keep the leading N, collapse the rest into one marker.
|
|
68
|
+
for (let k = i; k < i + KEEP_LEADING; k++) out.push(deep[k]);
|
|
69
|
+
const collapsed = runLength - KEEP_LEADING;
|
|
70
|
+
out.push({
|
|
71
|
+
type: 'text',
|
|
72
|
+
content: `[… ${collapsed} more similar items collapsed]`,
|
|
73
|
+
});
|
|
74
|
+
ctx.folded++;
|
|
75
|
+
} else {
|
|
76
|
+
for (let k = i; k < j; k++) out.push(deep[k]);
|
|
77
|
+
}
|
|
78
|
+
i = j;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Recursively fold a single node's children. */
|
|
84
|
+
function foldNode(node: CSTNode, ctx: FoldCtx): CSTNode {
|
|
85
|
+
if (!hasChildren(node)) return node;
|
|
86
|
+
return { ...node, children: foldList(node.children, ctx) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fold repetitive sibling chains within a node list.
|
|
91
|
+
*/
|
|
92
|
+
export function foldSiblings(children: CSTNode[]): FoldResult {
|
|
93
|
+
const ctx: FoldCtx = { folded: 0 };
|
|
94
|
+
const folded = foldList(children, ctx);
|
|
95
|
+
return { children: folded, foldedCount: ctx.folded };
|
|
96
|
+
}
|