@runtypelabs/persona 3.21.3 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/dist/animations/glyph-cycle.cjs +2 -262
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -235
- package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
- package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
- package/dist/animations/wipe.cjs +2 -72
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations/wipe.js +2 -45
- package/dist/index.cjs +52 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +474 -6
- package/dist/index.d.ts +474 -6
- package/dist/index.global.js +107 -97
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +52 -45
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +23 -0
- package/dist/smart-dom-reader.d.cts +4521 -0
- package/dist/smart-dom-reader.d.ts +4521 -0
- package/dist/smart-dom-reader.js +23 -0
- package/dist/testing.cjs +3 -84
- package/dist/testing.js +3 -55
- package/dist/theme-editor.cjs +57 -22501
- package/dist/theme-editor.d.cts +348 -1
- package/dist/theme-editor.d.ts +348 -1
- package/dist/theme-editor.js +57 -22503
- package/package.json +16 -6
- package/src/client.test.ts +165 -0
- package/src/client.ts +144 -23
- package/src/components/event-stream-view.ts +122 -1
- package/src/index.ts +26 -0
- package/src/session.test.ts +258 -0
- package/src/session.ts +886 -30
- package/src/session.webmcp.test.ts +815 -0
- package/src/smart-dom-reader.test.ts +135 -0
- package/src/smart-dom-reader.ts +135 -0
- package/src/theme-editor/color-utils.test.ts +59 -0
- package/src/theme-editor/color-utils.ts +38 -2
- package/src/theme-editor/index.ts +35 -0
- package/src/theme-editor/webmcp/coerce.test.ts +86 -0
- package/src/theme-editor/webmcp/coerce.ts +286 -0
- package/src/theme-editor/webmcp/index.ts +45 -0
- package/src/theme-editor/webmcp/summary.ts +324 -0
- package/src/theme-editor/webmcp/tools.test.ts +205 -0
- package/src/theme-editor/webmcp/tools.ts +795 -0
- package/src/theme-editor/webmcp/types.ts +87 -0
- package/src/types.ts +186 -0
- package/src/ui.composer-keyboard.test.ts +229 -0
- package/src/ui.ts +151 -8
- package/src/utils/composer-history.test.ts +128 -0
- package/src/utils/composer-history.ts +113 -0
- package/src/utils/message-fingerprint.test.ts +20 -0
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/smart-dom-adapter.test.ts +257 -0
- package/src/utils/smart-dom-adapter.ts +217 -0
- package/src/utils/throughput-tracker.test.ts +366 -0
- package/src/utils/throughput-tracker.ts +427 -0
- package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
- package/src/vendor/smart-dom-reader/README.md +61 -0
- package/src/vendor/smart-dom-reader/index.d.ts +476 -0
- package/src/vendor/smart-dom-reader/index.js +1618 -0
- package/src/webmcp-bridge.test.ts +429 -0
- package/src/webmcp-bridge.ts +547 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Pure mapper test — no DOM, no installed/vendored library at runtime (types only).
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { smartDomResultToEnriched } from "./smart-dom-adapter";
|
|
4
|
+
import { formatEnrichedContext } from "./dom-context";
|
|
5
|
+
import type {
|
|
6
|
+
SmartDOMResult,
|
|
7
|
+
ExtractedElement,
|
|
8
|
+
ElementSelector
|
|
9
|
+
} from "../vendor/smart-dom-reader";
|
|
10
|
+
|
|
11
|
+
function makeSelector(partial: Partial<ElementSelector> = {}): ElementSelector {
|
|
12
|
+
return { css: "div", xpath: "/html/body/div", ...partial };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeEl(partial: Partial<ExtractedElement> = {}): ExtractedElement {
|
|
16
|
+
return {
|
|
17
|
+
tag: partial.tag ?? "div",
|
|
18
|
+
text: partial.text ?? "",
|
|
19
|
+
selector: partial.selector ?? makeSelector(),
|
|
20
|
+
attributes: partial.attributes ?? {},
|
|
21
|
+
context: {
|
|
22
|
+
parentChain: [],
|
|
23
|
+
...(partial.context ?? {})
|
|
24
|
+
},
|
|
25
|
+
interaction: partial.interaction ?? {},
|
|
26
|
+
children: partial.children
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeResult(
|
|
31
|
+
interactive: Partial<SmartDOMResult["interactive"]> = {},
|
|
32
|
+
semantic?: SmartDOMResult["semantic"]
|
|
33
|
+
): SmartDOMResult {
|
|
34
|
+
return {
|
|
35
|
+
mode: semantic ? "full" : "interactive",
|
|
36
|
+
timestamp: 0,
|
|
37
|
+
page: {
|
|
38
|
+
url: "https://example.com",
|
|
39
|
+
title: "Example",
|
|
40
|
+
hasErrors: false,
|
|
41
|
+
isLoading: false,
|
|
42
|
+
hasModals: false
|
|
43
|
+
},
|
|
44
|
+
landmarks: {
|
|
45
|
+
navigation: [],
|
|
46
|
+
main: [],
|
|
47
|
+
forms: [],
|
|
48
|
+
headers: [],
|
|
49
|
+
footers: [],
|
|
50
|
+
articles: [],
|
|
51
|
+
sections: []
|
|
52
|
+
},
|
|
53
|
+
interactive: {
|
|
54
|
+
buttons: [],
|
|
55
|
+
links: [],
|
|
56
|
+
inputs: [],
|
|
57
|
+
forms: [],
|
|
58
|
+
clickable: [],
|
|
59
|
+
...interactive
|
|
60
|
+
},
|
|
61
|
+
semantic
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("smartDomResultToEnriched", () => {
|
|
66
|
+
it("prefers the highest-scoring plain-CSS candidate and skips XPath/text", () => {
|
|
67
|
+
const result = makeResult({
|
|
68
|
+
buttons: [
|
|
69
|
+
makeEl({
|
|
70
|
+
tag: "button",
|
|
71
|
+
text: "Add to cart",
|
|
72
|
+
interaction: { click: true },
|
|
73
|
+
selector: makeSelector({
|
|
74
|
+
css: "button.add",
|
|
75
|
+
xpath: "/html/body/button[1]",
|
|
76
|
+
candidates: [
|
|
77
|
+
{ type: "xpath", value: "/html/body/button[1]", score: 100 },
|
|
78
|
+
{ type: "text", value: "text=Add to cart", score: 90 },
|
|
79
|
+
{ type: "data-testid", value: '[data-testid="add"]', score: 80 },
|
|
80
|
+
{ type: "class-path", value: "button.add", score: 50 }
|
|
81
|
+
]
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
]
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const enriched = smartDomResultToEnriched(result);
|
|
88
|
+
expect(enriched).toHaveLength(1);
|
|
89
|
+
// best plain-CSS candidate by score (data-testid:80 > class-path:50; xpath/text skipped)
|
|
90
|
+
expect(enriched[0].selector).toBe('[data-testid="add"]');
|
|
91
|
+
expect(enriched[0].interactivity).toBe("clickable");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("falls back to selector.css when no candidates qualify", () => {
|
|
95
|
+
const result = makeResult({
|
|
96
|
+
buttons: [
|
|
97
|
+
makeEl({
|
|
98
|
+
tag: "button",
|
|
99
|
+
text: "Go",
|
|
100
|
+
interaction: { click: true },
|
|
101
|
+
selector: makeSelector({ css: "button.go", candidates: [] })
|
|
102
|
+
})
|
|
103
|
+
]
|
|
104
|
+
});
|
|
105
|
+
const enriched = smartDomResultToEnriched(result);
|
|
106
|
+
expect(enriched[0].selector).toBe("button.go");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("classifies interactivity from tag/role/interaction", () => {
|
|
110
|
+
const result = makeResult({
|
|
111
|
+
buttons: [
|
|
112
|
+
makeEl({
|
|
113
|
+
tag: "button",
|
|
114
|
+
text: "Buy",
|
|
115
|
+
interaction: { click: true },
|
|
116
|
+
selector: makeSelector({ css: "button.buy" })
|
|
117
|
+
})
|
|
118
|
+
],
|
|
119
|
+
links: [
|
|
120
|
+
makeEl({
|
|
121
|
+
tag: "a",
|
|
122
|
+
text: "Home",
|
|
123
|
+
attributes: { href: "/home" },
|
|
124
|
+
interaction: { nav: true },
|
|
125
|
+
selector: makeSelector({ css: "a.home" })
|
|
126
|
+
})
|
|
127
|
+
],
|
|
128
|
+
inputs: [
|
|
129
|
+
makeEl({
|
|
130
|
+
tag: "input",
|
|
131
|
+
attributes: { type: "text", name: "q" },
|
|
132
|
+
interaction: { change: true },
|
|
133
|
+
selector: makeSelector({ css: "input.q" })
|
|
134
|
+
})
|
|
135
|
+
]
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const enriched = smartDomResultToEnriched(result);
|
|
139
|
+
const bySel = Object.fromEntries(enriched.map((e) => [e.selector, e]));
|
|
140
|
+
expect(bySel["button.buy"].interactivity).toBe("clickable");
|
|
141
|
+
expect(bySel["a.home"].interactivity).toBe("navigable");
|
|
142
|
+
expect(bySel["input.q"].interactivity).toBe("input");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("excludes elements under the host (.persona-host) via parentChain", () => {
|
|
146
|
+
const result = makeResult({
|
|
147
|
+
buttons: [
|
|
148
|
+
makeEl({
|
|
149
|
+
tag: "button",
|
|
150
|
+
text: "Widget send",
|
|
151
|
+
interaction: { click: true },
|
|
152
|
+
selector: makeSelector({ css: "button.send" }),
|
|
153
|
+
context: { parentChain: ["div.persona-host", "div.panel"] }
|
|
154
|
+
}),
|
|
155
|
+
makeEl({
|
|
156
|
+
tag: "button",
|
|
157
|
+
text: "Page button",
|
|
158
|
+
interaction: { click: true },
|
|
159
|
+
selector: makeSelector({ css: "button.page" }),
|
|
160
|
+
context: { parentChain: ["main", "section"] }
|
|
161
|
+
})
|
|
162
|
+
]
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const enriched = smartDomResultToEnriched(result);
|
|
166
|
+
expect(enriched.map((e) => e.selector)).toEqual(["button.page"]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("includes semantic elements only when present and not disabled", () => {
|
|
170
|
+
const semantic: SmartDOMResult["semantic"] = {
|
|
171
|
+
headings: [
|
|
172
|
+
makeEl({
|
|
173
|
+
tag: "h1",
|
|
174
|
+
text: "Title",
|
|
175
|
+
selector: makeSelector({ css: "h1.title" })
|
|
176
|
+
})
|
|
177
|
+
],
|
|
178
|
+
images: [],
|
|
179
|
+
tables: [],
|
|
180
|
+
lists: [],
|
|
181
|
+
articles: []
|
|
182
|
+
};
|
|
183
|
+
const result = makeResult(
|
|
184
|
+
{
|
|
185
|
+
buttons: [
|
|
186
|
+
makeEl({
|
|
187
|
+
tag: "button",
|
|
188
|
+
text: "Go",
|
|
189
|
+
interaction: { click: true },
|
|
190
|
+
selector: makeSelector({ css: "button.go" })
|
|
191
|
+
})
|
|
192
|
+
]
|
|
193
|
+
},
|
|
194
|
+
semantic
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const withSemantic = smartDomResultToEnriched(result);
|
|
198
|
+
expect(withSemantic.map((e) => e.selector)).toContain("h1.title");
|
|
199
|
+
const heading = withSemantic.find((e) => e.selector === "h1.title");
|
|
200
|
+
expect(heading?.interactivity).toBe("static");
|
|
201
|
+
|
|
202
|
+
const withoutSemantic = smartDomResultToEnriched(result, {
|
|
203
|
+
includeSemantic: false
|
|
204
|
+
});
|
|
205
|
+
expect(withoutSemantic.map((e) => e.selector)).not.toContain("h1.title");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("deduplicates by selector and honors maxElements", () => {
|
|
209
|
+
const dup = makeEl({
|
|
210
|
+
tag: "button",
|
|
211
|
+
text: "Dup",
|
|
212
|
+
interaction: { click: true },
|
|
213
|
+
selector: makeSelector({ css: "button.dup" })
|
|
214
|
+
});
|
|
215
|
+
const result = makeResult({
|
|
216
|
+
buttons: [dup, dup],
|
|
217
|
+
clickable: [dup]
|
|
218
|
+
});
|
|
219
|
+
expect(smartDomResultToEnriched(result)).toHaveLength(1);
|
|
220
|
+
|
|
221
|
+
const many = makeResult({
|
|
222
|
+
buttons: [
|
|
223
|
+
makeEl({ tag: "button", text: "1", interaction: { click: true }, selector: makeSelector({ css: "button.a" }) }),
|
|
224
|
+
makeEl({ tag: "button", text: "2", interaction: { click: true }, selector: makeSelector({ css: "button.b" }) }),
|
|
225
|
+
makeEl({ tag: "button", text: "3", interaction: { click: true }, selector: makeSelector({ css: "button.c" }) })
|
|
226
|
+
]
|
|
227
|
+
});
|
|
228
|
+
expect(smartDomResultToEnriched(many, { maxElements: 2 })).toHaveLength(2);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("produces output that feeds formatEnrichedContext", () => {
|
|
232
|
+
const result = makeResult({
|
|
233
|
+
buttons: [
|
|
234
|
+
makeEl({
|
|
235
|
+
tag: "button",
|
|
236
|
+
text: "Checkout",
|
|
237
|
+
interaction: { click: true },
|
|
238
|
+
selector: makeSelector({ css: "button.checkout" })
|
|
239
|
+
})
|
|
240
|
+
],
|
|
241
|
+
links: [
|
|
242
|
+
makeEl({
|
|
243
|
+
tag: "a",
|
|
244
|
+
text: "Cart",
|
|
245
|
+
attributes: { href: "/cart" },
|
|
246
|
+
interaction: { nav: true },
|
|
247
|
+
selector: makeSelector({ css: "a.cart" })
|
|
248
|
+
})
|
|
249
|
+
]
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const formatted = formatEnrichedContext(smartDomResultToEnriched(result));
|
|
253
|
+
expect(formatted).toContain("button.checkout");
|
|
254
|
+
expect(formatted).toContain("a.cart");
|
|
255
|
+
expect(formatted).toContain("Checkout");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mapper: `@mcp-b/smart-dom-reader` output → Persona's {@link EnrichedPageElement}[].
|
|
3
|
+
*
|
|
4
|
+
* This module imports the smart-dom-reader types with `import type` ONLY, so the
|
|
5
|
+
* library's runtime value is never pulled in here. That keeps the mapper — and its
|
|
6
|
+
* unit test — free of any DOM and free of the (vendored) library at runtime; the
|
|
7
|
+
* types are erased during compilation.
|
|
8
|
+
*
|
|
9
|
+
* The smart-dom-reader returns a structured {@link SmartDOMResult} JSON object with
|
|
10
|
+
* rich selectors (CSS + XPath + ranked candidates). Persona's collect → reason → act
|
|
11
|
+
* loop drives clicks through `document.querySelector` (see `utils/actions.ts`), which
|
|
12
|
+
* cannot pierce shadow roots or evaluate XPath. So this mapper deliberately prefers
|
|
13
|
+
* the **best plain-CSS candidate selector** for each element and skips XPath / text
|
|
14
|
+
* pseudo-selectors, keeping results actionable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
SmartDOMResult,
|
|
19
|
+
ExtractedElement,
|
|
20
|
+
ElementSelectorCandidate
|
|
21
|
+
} from "../vendor/smart-dom-reader";
|
|
22
|
+
import type { EnrichedPageElement } from "./dom-context";
|
|
23
|
+
|
|
24
|
+
/** Options for {@link smartDomResultToEnriched}. */
|
|
25
|
+
export interface SmartDomAdapterOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Include non-interactive `semantic` groups (headings, images, tables, lists,
|
|
28
|
+
* articles) when the result has them (i.e. full-mode extraction). Default: true.
|
|
29
|
+
*/
|
|
30
|
+
includeSemantic?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Skip elements whose own selector / ancestor chain contains this selector string,
|
|
33
|
+
* so the widget never reports its own shadow-DOM UI. Matched as a substring against
|
|
34
|
+
* the element's candidate selectors and `context.parentChain`. Default: ".persona-host".
|
|
35
|
+
* Pass "" to disable.
|
|
36
|
+
*/
|
|
37
|
+
excludeSelector?: string;
|
|
38
|
+
/** Truncate each element's text to this many characters. Default: 200. */
|
|
39
|
+
maxTextLength?: number;
|
|
40
|
+
/** Optional cap on the number of mapped elements returned. */
|
|
41
|
+
maxElements?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Candidate selector types that resolve through `document.querySelector` (plain CSS).
|
|
46
|
+
* Everything else smart-dom-reader can emit — `xpath` and text pseudo-selectors —
|
|
47
|
+
* is not actionable via the current click loop, so it is skipped here.
|
|
48
|
+
*/
|
|
49
|
+
const PLAIN_CSS_CANDIDATE_TYPES: ReadonlySet<ElementSelectorCandidate["type"]> =
|
|
50
|
+
new Set(["id", "data-testid", "role-aria", "name", "class-path", "css-path"]);
|
|
51
|
+
|
|
52
|
+
/** Looks like an XPath expression rather than a CSS selector. */
|
|
53
|
+
function looksLikeXPath(value: string): boolean {
|
|
54
|
+
const v = value.trim();
|
|
55
|
+
return v.startsWith("/") || v.startsWith("(") || v.startsWith("./");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pick the best `document.querySelector`-able selector for an extracted element:
|
|
60
|
+
* the highest-scoring plain-CSS candidate, falling back to the primary `css` field
|
|
61
|
+
* when no candidate qualifies. Returns null when only XPath-like selectors exist.
|
|
62
|
+
*/
|
|
63
|
+
function bestPlainCssSelector(el: ExtractedElement): string | null {
|
|
64
|
+
const candidates = el.selector.candidates;
|
|
65
|
+
if (candidates && candidates.length > 0) {
|
|
66
|
+
let best: ElementSelectorCandidate | null = null;
|
|
67
|
+
for (const c of candidates) {
|
|
68
|
+
if (!PLAIN_CSS_CANDIDATE_TYPES.has(c.type)) continue;
|
|
69
|
+
if (!c.value || looksLikeXPath(c.value)) continue;
|
|
70
|
+
if (!best || c.score > best.score) best = c;
|
|
71
|
+
}
|
|
72
|
+
if (best) return best.value;
|
|
73
|
+
}
|
|
74
|
+
const css = el.selector.css;
|
|
75
|
+
if (css && !looksLikeXPath(css)) return css;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Classify an extracted element into Persona's interactivity buckets. */
|
|
80
|
+
function classifyInteractivity(
|
|
81
|
+
el: ExtractedElement
|
|
82
|
+
): EnrichedPageElement["interactivity"] {
|
|
83
|
+
const tag = el.tag.toLowerCase();
|
|
84
|
+
const role = el.interaction.role ?? el.attributes.role;
|
|
85
|
+
|
|
86
|
+
if (tag === "a" && (el.interaction.nav || el.attributes.href != null)) {
|
|
87
|
+
return "navigable";
|
|
88
|
+
}
|
|
89
|
+
if (tag === "input" || tag === "select" || tag === "textarea") return "input";
|
|
90
|
+
if (
|
|
91
|
+
role === "textbox" ||
|
|
92
|
+
role === "combobox" ||
|
|
93
|
+
role === "listbox" ||
|
|
94
|
+
role === "spinbutton"
|
|
95
|
+
) {
|
|
96
|
+
return "input";
|
|
97
|
+
}
|
|
98
|
+
if (el.interaction.change && !el.interaction.click) return "input";
|
|
99
|
+
if (
|
|
100
|
+
tag === "button" ||
|
|
101
|
+
role === "button" ||
|
|
102
|
+
el.interaction.click ||
|
|
103
|
+
el.interaction.submit
|
|
104
|
+
) {
|
|
105
|
+
return "clickable";
|
|
106
|
+
}
|
|
107
|
+
return "static";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Build the relevant-attribute bag, mirroring `dom-context.collectAttributes`. */
|
|
111
|
+
function collectAttributes(el: ExtractedElement): Record<string, string> {
|
|
112
|
+
const attrs: Record<string, string> = { ...el.attributes };
|
|
113
|
+
const role = el.interaction.role;
|
|
114
|
+
if (role && !attrs.role) attrs.role = role;
|
|
115
|
+
return attrs;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Returns true when the element sits under `excludeSelector` (e.g. the widget host).
|
|
120
|
+
* smart-dom-reader has no exclude option and pierces shadow DOM by default, so this
|
|
121
|
+
* guards against the widget reading its own UI. The check is a substring match across
|
|
122
|
+
* the element's candidate selectors and ancestor chain — robust for the default
|
|
123
|
+
* `.persona-host` class guard.
|
|
124
|
+
*/
|
|
125
|
+
function isExcluded(el: ExtractedElement, excludeSelector: string): boolean {
|
|
126
|
+
if (!excludeSelector) return false;
|
|
127
|
+
const haystacks: Array<string | undefined> = [
|
|
128
|
+
el.selector.css,
|
|
129
|
+
el.selector.xpath,
|
|
130
|
+
el.context.nearestForm,
|
|
131
|
+
el.context.nearestSection,
|
|
132
|
+
el.context.nearestMain,
|
|
133
|
+
el.context.nearestNav,
|
|
134
|
+
...(el.selector.candidates?.map((c) => c.value) ?? []),
|
|
135
|
+
...el.context.parentChain
|
|
136
|
+
];
|
|
137
|
+
return haystacks.some((h) => !!h && h.includes(excludeSelector));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Map a {@link SmartDOMResult} into Persona's {@link EnrichedPageElement}[] shape so it
|
|
142
|
+
* can be formatted by `formatEnrichedContext` and consumed wherever the default
|
|
143
|
+
* `collectEnrichedPageContext` output is.
|
|
144
|
+
*
|
|
145
|
+
* - Maps `interactive.{buttons, links, inputs, clickable}` and, when present and
|
|
146
|
+
* `includeSemantic` is not false, `semantic.{headings, images, tables, lists, articles}`.
|
|
147
|
+
* - Chooses the best plain-CSS selector per element (skipping XPath / shadow-piercing
|
|
148
|
+
* selectors) so results stay actionable via `document.querySelector`.
|
|
149
|
+
* - Drops elements under `excludeSelector` (default `.persona-host`).
|
|
150
|
+
* - Deduplicates by selector and preserves discovery order (interactive before semantic).
|
|
151
|
+
*/
|
|
152
|
+
export function smartDomResultToEnriched(
|
|
153
|
+
result: SmartDOMResult,
|
|
154
|
+
opts: SmartDomAdapterOptions = {}
|
|
155
|
+
): EnrichedPageElement[] {
|
|
156
|
+
const includeSemantic = opts.includeSemantic ?? true;
|
|
157
|
+
const excludeSelector = opts.excludeSelector ?? ".persona-host";
|
|
158
|
+
const maxTextLength = opts.maxTextLength ?? 200;
|
|
159
|
+
|
|
160
|
+
const groups: ExtractedElement[][] = [
|
|
161
|
+
result.interactive.buttons,
|
|
162
|
+
result.interactive.links,
|
|
163
|
+
result.interactive.inputs,
|
|
164
|
+
result.interactive.clickable
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
if (includeSemantic && result.semantic) {
|
|
168
|
+
groups.push(
|
|
169
|
+
result.semantic.headings,
|
|
170
|
+
result.semantic.images,
|
|
171
|
+
result.semantic.tables,
|
|
172
|
+
result.semantic.lists,
|
|
173
|
+
result.semantic.articles
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const out: EnrichedPageElement[] = [];
|
|
178
|
+
const seen = new Set<string>();
|
|
179
|
+
|
|
180
|
+
// Walk an element and any nested children. In full mode the library attaches
|
|
181
|
+
// shadow-DOM descendants as `children` of semantic containers, so recursing here
|
|
182
|
+
// is how those pierced elements surface. Returns false once the optional
|
|
183
|
+
// maxElements cap is reached.
|
|
184
|
+
const visit = (el: ExtractedElement): boolean => {
|
|
185
|
+
if (isExcluded(el, excludeSelector)) return true;
|
|
186
|
+
|
|
187
|
+
const selector = bestPlainCssSelector(el);
|
|
188
|
+
if (selector && !seen.has(selector)) {
|
|
189
|
+
seen.add(selector);
|
|
190
|
+
out.push({
|
|
191
|
+
selector,
|
|
192
|
+
tagName: el.tag.toLowerCase(),
|
|
193
|
+
text: (el.text ?? "").trim().substring(0, maxTextLength),
|
|
194
|
+
role: el.interaction.role ?? el.attributes.role ?? null,
|
|
195
|
+
interactivity: classifyInteractivity(el),
|
|
196
|
+
attributes: collectAttributes(el)
|
|
197
|
+
});
|
|
198
|
+
if (opts.maxElements && out.length >= opts.maxElements) return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (el.children) {
|
|
202
|
+
for (const child of el.children) {
|
|
203
|
+
if (!visit(child)) return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
for (const group of groups) {
|
|
210
|
+
if (!group) continue;
|
|
211
|
+
for (const el of group) {
|
|
212
|
+
if (!visit(el)) return out;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return out;
|
|
217
|
+
}
|