@runtypelabs/persona 3.21.3 → 3.22.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.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- 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.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +50 -43
- 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 +98 -88
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -41
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +1875 -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 +1848 -0
- package/dist/theme-editor.cjs +2281 -84
- package/dist/theme-editor.d.cts +348 -1
- package/dist/theme-editor.d.ts +348 -1
- package/dist/theme-editor.js +2260 -78
- package/package.json +9 -2
- package/src/client.test.ts +165 -0
- package/src/client.ts +144 -23
- 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 +127 -5
- 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/{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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c)
|
|
3
|
+
Copyright (c) 2025 mcp-b contributors
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
|
18
18
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Vendored: `@mcp-b/smart-dom-reader`
|
|
2
|
+
|
|
3
|
+
This directory contains a **vendored copy** of [`@mcp-b/smart-dom-reader`](https://github.com/WebMCP-org/npm-packages/tree/main/packages/smart-dom-reader)
|
|
4
|
+
(v2.3.1, MIT, © 2025 mcp-b contributors), consumed only by the optional
|
|
5
|
+
`@runtypelabs/persona/smart-dom-reader` entry point (`src/smart-dom-reader.ts`)
|
|
6
|
+
and the pure mapper (`src/utils/smart-dom-adapter.ts`, type-only).
|
|
7
|
+
|
|
8
|
+
## Why vendored instead of a dependency
|
|
9
|
+
|
|
10
|
+
Every published version of the package (2.3.1, 2.3.2, 3.0.0) is **mis-published**:
|
|
11
|
+
its `package.json` declares
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
but the build tool (`vp pack`, vite-plus/rolldown) only emits `dist/index.mjs` +
|
|
20
|
+
`dist/index.d.mts`. The files referenced by `package.json` are **absent from the
|
|
21
|
+
tarball**, so the package cannot be imported by name in Node or any bundler
|
|
22
|
+
(`ERR_MODULE_NOT_FOUND`), and TypeScript cannot resolve its types. This affects
|
|
23
|
+
the package itself and every downstream consumer, so making it an optional peer
|
|
24
|
+
dependency would ship a feature that no integrator could actually load.
|
|
25
|
+
|
|
26
|
+
Vendoring the built artifact into this opt-in entry sidesteps the broken module
|
|
27
|
+
resolution entirely: the code is bundled into `dist/smart-dom-reader.{js,cjs}`
|
|
28
|
+
and never touches a package resolver. Consumers who never import the
|
|
29
|
+
`/smart-dom-reader` entry pay nothing.
|
|
30
|
+
|
|
31
|
+
> **Follow-up:** raise the packaging bug with the upstream maintainer. Once a
|
|
32
|
+
> corrected release exists, this vendor dir can be replaced with a normal
|
|
33
|
+
> optional peer dependency (see the original plan).
|
|
34
|
+
|
|
35
|
+
## Files
|
|
36
|
+
|
|
37
|
+
- `index.js` — upstream `dist/index.mjs`, with two local edits (see header comment).
|
|
38
|
+
- `index.d.ts` — upstream `dist/index.d.mts` verbatim (sourceMappingURL stripped).
|
|
39
|
+
- `LICENSE` — upstream MIT license.
|
|
40
|
+
|
|
41
|
+
## Local modifications to `index.js`
|
|
42
|
+
|
|
43
|
+
The **only** changes from upstream `dist/index.mjs` are:
|
|
44
|
+
|
|
45
|
+
1. Removed the top-level `import { createRequire } from "node:module";` — a
|
|
46
|
+
Node-only builtin that breaks browser bundling.
|
|
47
|
+
2. Replaced `var __require = createRequire(import.meta.url);` with
|
|
48
|
+
`var __require = void 0;`. `__require` is referenced only inside a guarded
|
|
49
|
+
`typeof __require === "function"` Node fallback in `resolveSmartDomReader()`;
|
|
50
|
+
the browser path (`typeof window !== "undefined"`) returns before reaching it,
|
|
51
|
+
so this is a runtime no-op in the browser.
|
|
52
|
+
|
|
53
|
+
## How to update
|
|
54
|
+
|
|
55
|
+
1. `npm pack @mcp-b/smart-dom-reader@<version>` and extract the tarball.
|
|
56
|
+
2. Copy `dist/index.mjs` → `index.js` and `dist/index.d.mts` → `index.d.ts`.
|
|
57
|
+
3. Re-apply the two edits above (strip the `node:module` import; neutralize
|
|
58
|
+
`__require`) and the provenance headers. Strip trailing `sourceMappingURL`
|
|
59
|
+
comments.
|
|
60
|
+
4. Copy the upstream `LICENSE`.
|
|
61
|
+
5. Re-run `pnpm --filter @runtypelabs/persona build typecheck test:run`.
|