@runtypelabs/persona 1.48.0 → 2.0.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 +140 -8
- package/dist/index.cjs +90 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1055 -24
- package/dist/index.d.ts +1055 -24
- package/dist/index.global.js +111 -60
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +90 -39
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/widget.css +836 -513
- package/package.json +1 -1
- package/src/artifacts-session.test.ts +80 -0
- package/src/client.test.ts +20 -21
- package/src/client.ts +153 -4
- package/src/components/approval-bubble.ts +45 -42
- package/src/components/artifact-card.ts +91 -0
- package/src/components/artifact-pane.ts +501 -0
- package/src/components/composer-builder.ts +32 -27
- package/src/components/event-stream-view.ts +40 -40
- package/src/components/feedback.ts +36 -36
- package/src/components/forms.ts +11 -11
- package/src/components/header-builder.test.ts +32 -0
- package/src/components/header-builder.ts +55 -36
- package/src/components/header-layouts.ts +58 -125
- package/src/components/launcher.ts +36 -21
- package/src/components/message-bubble.ts +92 -65
- package/src/components/messages.ts +2 -2
- package/src/components/panel.ts +42 -11
- package/src/components/reasoning-bubble.ts +23 -23
- package/src/components/registry.ts +4 -0
- package/src/components/suggestions.ts +1 -1
- package/src/components/tool-bubble.ts +32 -32
- package/src/defaults.ts +30 -4
- package/src/index.ts +80 -2
- package/src/install.ts +22 -0
- package/src/plugins/types.ts +23 -0
- package/src/postprocessors.ts +2 -2
- package/src/runtime/host-layout.ts +174 -0
- package/src/runtime/init.test.ts +236 -0
- package/src/runtime/init.ts +114 -55
- package/src/session.ts +135 -2
- package/src/styles/tailwind.css +1 -1
- package/src/styles/widget.css +836 -513
- package/src/types/theme.ts +354 -0
- package/src/types.ts +314 -15
- package/src/ui.docked.test.ts +104 -0
- package/src/ui.ts +940 -227
- package/src/utils/artifact-gate.test.ts +255 -0
- package/src/utils/artifact-gate.ts +142 -0
- package/src/utils/artifact-resize.test.ts +64 -0
- package/src/utils/artifact-resize.ts +67 -0
- package/src/utils/attachment-manager.ts +10 -10
- package/src/utils/code-generators.test.ts +52 -0
- package/src/utils/code-generators.ts +40 -36
- package/src/utils/dock.ts +17 -0
- package/src/utils/dom-context.test.ts +504 -0
- package/src/utils/dom-context.ts +896 -0
- package/src/utils/dom.ts +12 -1
- package/src/utils/message-fingerprint.test.ts +187 -0
- package/src/utils/message-fingerprint.ts +105 -0
- package/src/utils/migration.ts +179 -0
- package/src/utils/morph.ts +1 -1
- package/src/utils/plugins.ts +175 -0
- package/src/utils/positioning.ts +4 -4
- package/src/utils/theme.test.ts +125 -0
- package/src/utils/theme.ts +216 -60
- package/src/utils/tokens.ts +682 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enriched DOM context collection for providing richer page information to AI.
|
|
3
|
+
*
|
|
4
|
+
* Captures interactive elements, stable CSS selectors, ARIA roles, data attributes,
|
|
5
|
+
* and visibility state — giving the LLM much better context than basic className/innerText.
|
|
6
|
+
*
|
|
7
|
+
* ## Modes
|
|
8
|
+
*
|
|
9
|
+
* - **structured** (default): collects candidates, scores them with optional {@link ParseRule}
|
|
10
|
+
* hooks, then applies `maxElements`. Rich containers (e.g. product cards) can surface
|
|
11
|
+
* before unrelated static noise.
|
|
12
|
+
* - **simple**: legacy behavior — cap during traversal, interactive-first ordering, no rule
|
|
13
|
+
* scoring or {@link EnrichedPageElement.formattedSummary}.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface EnrichedPageElement {
|
|
17
|
+
/** Stable CSS selector the LLM can use directly */
|
|
18
|
+
selector: string;
|
|
19
|
+
/** Lowercase tag name */
|
|
20
|
+
tagName: string;
|
|
21
|
+
/** Visible text content, trimmed */
|
|
22
|
+
text: string;
|
|
23
|
+
/** ARIA role or null */
|
|
24
|
+
role: string | null;
|
|
25
|
+
/** Interactivity classification */
|
|
26
|
+
interactivity: "clickable" | "input" | "navigable" | "static";
|
|
27
|
+
/** Relevant attributes: id, data-*, href, aria-label, type, value, name */
|
|
28
|
+
attributes: Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* When set (structured mode + matching rule), {@link formatEnrichedContext} prefers this
|
|
31
|
+
* markdown-like line instead of raw `text`.
|
|
32
|
+
*/
|
|
33
|
+
formattedSummary?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How DOM context is collected and formatted. */
|
|
37
|
+
export type DomContextMode = "simple" | "structured";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Options that control collection limits, visibility, and mode.
|
|
41
|
+
* Prefer nesting these under {@link DomContextOptions.options}; top-level fields remain
|
|
42
|
+
* supported for backward compatibility.
|
|
43
|
+
*/
|
|
44
|
+
export interface ParseOptionsConfig {
|
|
45
|
+
/**
|
|
46
|
+
* `structured` (default): score candidates with rules, then apply `maxElements`.
|
|
47
|
+
* `simple`: legacy traversal cap and ordering only — rules are ignored (with a warning
|
|
48
|
+
* if `rules` was passed on {@link DomContextOptions}).
|
|
49
|
+
*/
|
|
50
|
+
mode?: DomContextMode;
|
|
51
|
+
/** Maximum number of elements to return. Default: 80 */
|
|
52
|
+
maxElements?: number;
|
|
53
|
+
/** CSS selector for elements to exclude (e.g. the widget host). Default: '.persona-host' */
|
|
54
|
+
excludeSelector?: string;
|
|
55
|
+
/** Maximum text length per element. Default: 200 */
|
|
56
|
+
maxTextLength?: number;
|
|
57
|
+
/** Only include visible elements. Default: true */
|
|
58
|
+
visibleOnly?: boolean;
|
|
59
|
+
/** Root element to walk. Default: document.body */
|
|
60
|
+
root?: HTMLElement;
|
|
61
|
+
/**
|
|
62
|
+
* Maximum candidates gathered before scoring (structured mode only).
|
|
63
|
+
* Default: `max(500, maxElements * 10)`.
|
|
64
|
+
*/
|
|
65
|
+
maxCandidates?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RuleScoringContext {
|
|
69
|
+
doc: Document;
|
|
70
|
+
maxTextLength: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Extensible rule for structured DOM context: scoring, descendant suppression, and
|
|
75
|
+
* optional formatted output.
|
|
76
|
+
*/
|
|
77
|
+
export interface ParseRule {
|
|
78
|
+
/** Stable id for debugging and tests */
|
|
79
|
+
id: string;
|
|
80
|
+
/**
|
|
81
|
+
* Score bonus when this rule applies to the element (0 when it does not).
|
|
82
|
+
* Higher scores are kept first when applying `maxElements`.
|
|
83
|
+
*/
|
|
84
|
+
scoreElement(
|
|
85
|
+
el: HTMLElement,
|
|
86
|
+
enriched: EnrichedPageElement,
|
|
87
|
+
ctx: RuleScoringContext
|
|
88
|
+
): number;
|
|
89
|
+
/**
|
|
90
|
+
* When `owner` is kept in the final set and matched this rule for formatting,
|
|
91
|
+
* return true to drop `descendant` (redundant price text, CTAs summarized on the card, etc.).
|
|
92
|
+
*/
|
|
93
|
+
shouldSuppressDescendant?(
|
|
94
|
+
owner: HTMLElement,
|
|
95
|
+
descendant: HTMLElement,
|
|
96
|
+
descendantEnriched: EnrichedPageElement
|
|
97
|
+
): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Markdown-like summary for the LLM. Only used when `scoreElement` > 0 for this rule.
|
|
100
|
+
*/
|
|
101
|
+
formatSummary?(
|
|
102
|
+
el: HTMLElement,
|
|
103
|
+
enriched: EnrichedPageElement,
|
|
104
|
+
ctx: RuleScoringContext
|
|
105
|
+
): string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DomContextOptions {
|
|
109
|
+
/** Nested parse options (mode, limits, root). Merged with legacy top-level fields. */
|
|
110
|
+
options?: ParseOptionsConfig;
|
|
111
|
+
/** Custom rules for structured mode. Default: {@link defaultParseRules} */
|
|
112
|
+
rules?: ParseRule[];
|
|
113
|
+
/** @inheritdoc ParseOptionsConfig.maxElements */
|
|
114
|
+
maxElements?: number;
|
|
115
|
+
/** @inheritdoc ParseOptionsConfig.excludeSelector */
|
|
116
|
+
excludeSelector?: string;
|
|
117
|
+
/** @inheritdoc ParseOptionsConfig.maxTextLength */
|
|
118
|
+
maxTextLength?: number;
|
|
119
|
+
/** @inheritdoc ParseOptionsConfig.visibleOnly */
|
|
120
|
+
visibleOnly?: boolean;
|
|
121
|
+
/** @inheritdoc ParseOptionsConfig.root */
|
|
122
|
+
root?: HTMLElement;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface FormatEnrichedContextOptions {
|
|
126
|
+
/** When `simple`, ignore {@link EnrichedPageElement.formattedSummary}. Default: structured */
|
|
127
|
+
mode?: DomContextMode;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SKIP_TAGS = new Set([
|
|
131
|
+
"script",
|
|
132
|
+
"style",
|
|
133
|
+
"noscript",
|
|
134
|
+
"svg",
|
|
135
|
+
"path",
|
|
136
|
+
"meta",
|
|
137
|
+
"link",
|
|
138
|
+
"br",
|
|
139
|
+
"hr",
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
const INTERACTIVE_TAGS = new Set([
|
|
143
|
+
"button",
|
|
144
|
+
"a",
|
|
145
|
+
"input",
|
|
146
|
+
"select",
|
|
147
|
+
"textarea",
|
|
148
|
+
"details",
|
|
149
|
+
"summary",
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const INTERACTIVE_ROLES = new Set([
|
|
153
|
+
"button",
|
|
154
|
+
"link",
|
|
155
|
+
"menuitem",
|
|
156
|
+
"tab",
|
|
157
|
+
"option",
|
|
158
|
+
"switch",
|
|
159
|
+
"checkbox",
|
|
160
|
+
"radio",
|
|
161
|
+
"combobox",
|
|
162
|
+
"listbox",
|
|
163
|
+
"slider",
|
|
164
|
+
"spinbutton",
|
|
165
|
+
"textbox",
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
/** Class / id / data-* value hints for card-like containers */
|
|
169
|
+
const CARD_HINT_RE = /\b(product|card|item|listing|result)\b/i;
|
|
170
|
+
|
|
171
|
+
/** Currency-like text in subtree */
|
|
172
|
+
const CURRENCY_RE =
|
|
173
|
+
/\$[\d,]+(?:\.\d{2})?|€[\d,]+(?:\.\d{2})?|£[\d,]+(?:\.\d{2})?|USD\s*[\d,]+(?:\.\d{2})?/i;
|
|
174
|
+
|
|
175
|
+
const BASE_SCORE_INTERACTIVE = 3000;
|
|
176
|
+
const BASE_SCORE_STATIC = 100;
|
|
177
|
+
|
|
178
|
+
function hasCardHint(el: HTMLElement): boolean {
|
|
179
|
+
const cls = typeof el.className === "string" ? el.className : "";
|
|
180
|
+
if (CARD_HINT_RE.test(cls)) return true;
|
|
181
|
+
if (el.id && CARD_HINT_RE.test(el.id)) return true;
|
|
182
|
+
for (let i = 0; i < el.attributes.length; i++) {
|
|
183
|
+
const a = el.attributes[i];
|
|
184
|
+
if (a.name.startsWith("data-") && CARD_HINT_RE.test(a.value)) return true;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function subtreeHasCurrency(el: HTMLElement): boolean {
|
|
190
|
+
return CURRENCY_RE.test((el.textContent ?? "").trim());
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function subtreeHasNonTrivialLink(el: HTMLElement): boolean {
|
|
194
|
+
const anchors = el.querySelectorAll("a[href]");
|
|
195
|
+
for (let i = 0; i < anchors.length; i++) {
|
|
196
|
+
const href = (anchors[i] as HTMLAnchorElement).getAttribute("href") ?? "";
|
|
197
|
+
if (href && href !== "#" && !href.toLowerCase().startsWith("javascript:"))
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function subtreeHasButtonLike(el: HTMLElement): boolean {
|
|
204
|
+
return !!el.querySelector(
|
|
205
|
+
'button, [role="button"], input[type="submit"], input[type="button"]'
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function extractFirstPrice(text: string): string | null {
|
|
210
|
+
const m = text.match(CURRENCY_RE);
|
|
211
|
+
return m ? m[0] : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function extractTitleAndHref(el: HTMLElement): { title: string; href: string | null } {
|
|
215
|
+
const link =
|
|
216
|
+
el.querySelector(
|
|
217
|
+
".product-title a, h1 a, h2 a, h3 a, h4 a, .title a, a[href]"
|
|
218
|
+
) ?? el.querySelector("a[href]");
|
|
219
|
+
if (link && link.textContent?.trim()) {
|
|
220
|
+
const href = (link as HTMLAnchorElement).getAttribute("href");
|
|
221
|
+
return {
|
|
222
|
+
title: link.textContent.trim(),
|
|
223
|
+
href: href && href !== "#" ? href : null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const heading = el.querySelector("h1, h2, h3, h4, h5, h6");
|
|
227
|
+
if (heading?.textContent?.trim()) {
|
|
228
|
+
return { title: heading.textContent.trim(), href: null };
|
|
229
|
+
}
|
|
230
|
+
return { title: "", href: null };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function extractCtaLabels(el: HTMLElement): string[] {
|
|
234
|
+
const labels: string[] = [];
|
|
235
|
+
const push = (s: string) => {
|
|
236
|
+
const t = s.trim();
|
|
237
|
+
if (t && !labels.includes(t)) labels.push(t);
|
|
238
|
+
};
|
|
239
|
+
el.querySelectorAll("button").forEach((b) => push(b.textContent ?? ""));
|
|
240
|
+
el.querySelectorAll('[role="button"]').forEach((b) => push(b.textContent ?? ""));
|
|
241
|
+
el.querySelectorAll('input[type="submit"], input[type="button"]').forEach((inp) => {
|
|
242
|
+
push((inp as HTMLInputElement).value ?? "");
|
|
243
|
+
});
|
|
244
|
+
return labels.slice(0, 6);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const COMMERCE_CARD_RULE_ID = "commerce-card";
|
|
248
|
+
export const RESULT_CARD_RULE_ID = "result-card";
|
|
249
|
+
|
|
250
|
+
function commerceCardScore(el: HTMLElement): number {
|
|
251
|
+
if (!hasCardHint(el)) return 0;
|
|
252
|
+
if (!subtreeHasCurrency(el)) return 0;
|
|
253
|
+
if (!subtreeHasNonTrivialLink(el) && !subtreeHasButtonLike(el)) return 0;
|
|
254
|
+
return 5200;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resultCardScore(el: HTMLElement): number {
|
|
258
|
+
if (!hasCardHint(el)) return 0;
|
|
259
|
+
if (subtreeHasCurrency(el)) return 0;
|
|
260
|
+
if (!subtreeHasNonTrivialLink(el)) return 0;
|
|
261
|
+
const text = (el.textContent ?? "").trim();
|
|
262
|
+
if (text.length < 20) return 0;
|
|
263
|
+
const hasTitle =
|
|
264
|
+
!!el.querySelector("h1, h2, h3, h4, h5, h6, .title") ||
|
|
265
|
+
!!el.querySelector(".snippet, .description, p");
|
|
266
|
+
if (!hasTitle) return 0;
|
|
267
|
+
return 2800;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Default structured rules: commerce-style cards and generic search/result rows. */
|
|
271
|
+
export const defaultParseRules: ParseRule[] = [
|
|
272
|
+
{
|
|
273
|
+
id: COMMERCE_CARD_RULE_ID,
|
|
274
|
+
scoreElement(el) {
|
|
275
|
+
return commerceCardScore(el);
|
|
276
|
+
},
|
|
277
|
+
shouldSuppressDescendant(owner, descendant, enriched) {
|
|
278
|
+
if (descendant === owner || !owner.contains(descendant)) return false;
|
|
279
|
+
if (enriched.interactivity === "static") {
|
|
280
|
+
const t = enriched.text.trim();
|
|
281
|
+
if (t.length === 0) return true;
|
|
282
|
+
if (CURRENCY_RE.test(t) && t.length < 32) return true;
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
},
|
|
287
|
+
formatSummary(el, enriched) {
|
|
288
|
+
if (commerceCardScore(el) === 0) return null;
|
|
289
|
+
const { title, href } = extractTitleAndHref(el);
|
|
290
|
+
const price =
|
|
291
|
+
extractFirstPrice((el.textContent ?? "").trim()) ??
|
|
292
|
+
extractFirstPrice(enriched.text) ??
|
|
293
|
+
"";
|
|
294
|
+
const ctas = extractCtaLabels(el);
|
|
295
|
+
const head =
|
|
296
|
+
href && title
|
|
297
|
+
? `[${title}](${href})${price ? ` — ${price}` : ""}`
|
|
298
|
+
: title
|
|
299
|
+
? `${title}${price ? ` — ${price}` : ""}`
|
|
300
|
+
: price || enriched.text.trim().slice(0, 120);
|
|
301
|
+
const lines = [
|
|
302
|
+
head,
|
|
303
|
+
`selector: ${enriched.selector}`,
|
|
304
|
+
ctas.length ? `actions: ${ctas.join(", ")}` : "",
|
|
305
|
+
].filter(Boolean);
|
|
306
|
+
return lines.join("\n");
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: RESULT_CARD_RULE_ID,
|
|
311
|
+
scoreElement(el) {
|
|
312
|
+
return resultCardScore(el);
|
|
313
|
+
},
|
|
314
|
+
formatSummary(el, enriched) {
|
|
315
|
+
if (resultCardScore(el) === 0) return null;
|
|
316
|
+
const { title, href } = extractTitleAndHref(el);
|
|
317
|
+
const head =
|
|
318
|
+
href && title
|
|
319
|
+
? `[${title}](${href})`
|
|
320
|
+
: title || enriched.text.trim().slice(0, 120);
|
|
321
|
+
const lines = [head, `selector: ${enriched.selector}`].filter(Boolean);
|
|
322
|
+
return lines.join("\n");
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
interface ResolvedDomContextConfig {
|
|
328
|
+
mode: DomContextMode;
|
|
329
|
+
maxElements: number;
|
|
330
|
+
maxCandidates: number;
|
|
331
|
+
excludeSelector: string;
|
|
332
|
+
maxTextLength: number;
|
|
333
|
+
visibleOnly: boolean;
|
|
334
|
+
root: HTMLElement | undefined;
|
|
335
|
+
rules: ParseRule[];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function warnSimpleWithRules(): void {
|
|
339
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
340
|
+
console.warn(
|
|
341
|
+
"[persona] collectEnrichedPageContext: options.mode is \"simple\" but `rules` were provided; rules are ignored."
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function resolveDomContextConfig(options: DomContextOptions): ResolvedDomContextConfig {
|
|
347
|
+
const nested = options.options ?? {};
|
|
348
|
+
const maxElements =
|
|
349
|
+
nested.maxElements ?? options.maxElements ?? 80;
|
|
350
|
+
const excludeSelector =
|
|
351
|
+
nested.excludeSelector ?? options.excludeSelector ?? ".persona-host";
|
|
352
|
+
const maxTextLength =
|
|
353
|
+
nested.maxTextLength ?? options.maxTextLength ?? 200;
|
|
354
|
+
const visibleOnly =
|
|
355
|
+
nested.visibleOnly ?? options.visibleOnly ?? true;
|
|
356
|
+
const root = nested.root ?? options.root;
|
|
357
|
+
const mode: DomContextMode = nested.mode ?? "structured";
|
|
358
|
+
const maxCandidates =
|
|
359
|
+
nested.maxCandidates ?? Math.max(500, maxElements * 10);
|
|
360
|
+
|
|
361
|
+
let rules = options.rules ?? defaultParseRules;
|
|
362
|
+
if (mode === "simple" && options.rules && options.rules.length > 0) {
|
|
363
|
+
warnSimpleWithRules();
|
|
364
|
+
rules = [];
|
|
365
|
+
} else if (mode === "simple") {
|
|
366
|
+
rules = [];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
mode,
|
|
371
|
+
maxElements,
|
|
372
|
+
maxCandidates,
|
|
373
|
+
excludeSelector,
|
|
374
|
+
maxTextLength,
|
|
375
|
+
visibleOnly,
|
|
376
|
+
root,
|
|
377
|
+
rules,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Escape a string for use in CSS selectors. Falls back to simple escaping
|
|
383
|
+
* when CSS.escape is not available (e.g. in jsdom).
|
|
384
|
+
*/
|
|
385
|
+
function cssEscape(str: string): string {
|
|
386
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
387
|
+
return CSS.escape(str);
|
|
388
|
+
}
|
|
389
|
+
return str.replace(/([^\w-])/g, "\\$1");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const DATA_ATTR_PRIORITY = [
|
|
393
|
+
"data-testid",
|
|
394
|
+
"data-product",
|
|
395
|
+
"data-action",
|
|
396
|
+
"data-id",
|
|
397
|
+
"data-name",
|
|
398
|
+
"data-type",
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Classify an element's interactivity type.
|
|
403
|
+
*/
|
|
404
|
+
function classifyInteractivity(
|
|
405
|
+
el: HTMLElement
|
|
406
|
+
): EnrichedPageElement["interactivity"] {
|
|
407
|
+
const tag = el.tagName.toLowerCase();
|
|
408
|
+
const role = el.getAttribute("role");
|
|
409
|
+
|
|
410
|
+
if (tag === "a" && el.hasAttribute("href")) return "navigable";
|
|
411
|
+
if (tag === "input" || tag === "select" || tag === "textarea") return "input";
|
|
412
|
+
if (
|
|
413
|
+
role === "textbox" ||
|
|
414
|
+
role === "combobox" ||
|
|
415
|
+
role === "listbox" ||
|
|
416
|
+
role === "spinbutton"
|
|
417
|
+
)
|
|
418
|
+
return "input";
|
|
419
|
+
if (tag === "button" || role === "button") return "clickable";
|
|
420
|
+
if (
|
|
421
|
+
INTERACTIVE_TAGS.has(tag) ||
|
|
422
|
+
(role && INTERACTIVE_ROLES.has(role)) ||
|
|
423
|
+
el.hasAttribute("tabindex") ||
|
|
424
|
+
el.hasAttribute("onclick") ||
|
|
425
|
+
el.getAttribute("contenteditable") === "true"
|
|
426
|
+
)
|
|
427
|
+
return "clickable";
|
|
428
|
+
|
|
429
|
+
return "static";
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Check if an element is visible.
|
|
434
|
+
* Uses a defensive approach: only marks as invisible when we have positive evidence
|
|
435
|
+
* of hidden state (display:none, visibility:hidden, hidden attribute).
|
|
436
|
+
* offsetParent is unreliable in non-layout environments (e.g. jsdom).
|
|
437
|
+
*/
|
|
438
|
+
function isElementVisible(el: HTMLElement): boolean {
|
|
439
|
+
if (el.hidden) return false;
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const style = getComputedStyle(el);
|
|
443
|
+
if (style.display === "none") return false;
|
|
444
|
+
if (style.visibility === "hidden") return false;
|
|
445
|
+
} catch {
|
|
446
|
+
// getComputedStyle can fail in some environments — assume visible
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (el.style.display === "none") return false;
|
|
450
|
+
if (el.style.visibility === "hidden") return false;
|
|
451
|
+
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Collect relevant attributes from an element.
|
|
457
|
+
*/
|
|
458
|
+
function collectAttributes(el: HTMLElement): Record<string, string> {
|
|
459
|
+
const attrs: Record<string, string> = {};
|
|
460
|
+
|
|
461
|
+
const id = el.id;
|
|
462
|
+
if (id) attrs["id"] = id;
|
|
463
|
+
|
|
464
|
+
const href = el.getAttribute("href");
|
|
465
|
+
if (href) attrs["href"] = href;
|
|
466
|
+
|
|
467
|
+
const ariaLabel = el.getAttribute("aria-label");
|
|
468
|
+
if (ariaLabel) attrs["aria-label"] = ariaLabel;
|
|
469
|
+
|
|
470
|
+
const type = el.getAttribute("type");
|
|
471
|
+
if (type) attrs["type"] = type;
|
|
472
|
+
|
|
473
|
+
const value = el.getAttribute("value");
|
|
474
|
+
if (value) attrs["value"] = value;
|
|
475
|
+
|
|
476
|
+
const name = el.getAttribute("name");
|
|
477
|
+
if (name) attrs["name"] = name;
|
|
478
|
+
|
|
479
|
+
const role = el.getAttribute("role");
|
|
480
|
+
if (role) attrs["role"] = role;
|
|
481
|
+
|
|
482
|
+
for (let i = 0; i < el.attributes.length; i++) {
|
|
483
|
+
const attr = el.attributes[i];
|
|
484
|
+
if (attr.name.startsWith("data-")) {
|
|
485
|
+
attrs[attr.name] = attr.value;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return attrs;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Generate a stable, unique CSS selector for an element.
|
|
494
|
+
* Priority: #id → [data-testid]/[data-product] → tag.classes with :nth-of-type()
|
|
495
|
+
*/
|
|
496
|
+
export function generateStableSelector(el: HTMLElement): string {
|
|
497
|
+
const tag = el.tagName.toLowerCase();
|
|
498
|
+
|
|
499
|
+
if (el.id) {
|
|
500
|
+
const sel = `#${cssEscape(el.id)}`;
|
|
501
|
+
try {
|
|
502
|
+
if (el.ownerDocument.querySelectorAll(sel).length === 1) return sel;
|
|
503
|
+
} catch {
|
|
504
|
+
// invalid selector, fall through
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
for (const attr of DATA_ATTR_PRIORITY) {
|
|
509
|
+
const val = el.getAttribute(attr);
|
|
510
|
+
if (val) {
|
|
511
|
+
const sel = `${tag}[${attr}="${cssEscape(val)}"]`;
|
|
512
|
+
try {
|
|
513
|
+
if (el.ownerDocument.querySelectorAll(sel).length === 1) return sel;
|
|
514
|
+
} catch {
|
|
515
|
+
// invalid selector, fall through
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const classes = Array.from(el.classList)
|
|
521
|
+
.filter((c) => c && !c.startsWith("persona-"))
|
|
522
|
+
.slice(0, 3);
|
|
523
|
+
|
|
524
|
+
if (classes.length > 0) {
|
|
525
|
+
const classSel = `${tag}.${classes.map((c) => cssEscape(c)).join(".")}`;
|
|
526
|
+
try {
|
|
527
|
+
if (el.ownerDocument.querySelectorAll(classSel).length === 1) return classSel;
|
|
528
|
+
} catch {
|
|
529
|
+
// fall through
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const parent = el.parentElement;
|
|
533
|
+
if (parent) {
|
|
534
|
+
const siblings = Array.from(parent.querySelectorAll(`:scope > ${tag}`));
|
|
535
|
+
const index = siblings.indexOf(el);
|
|
536
|
+
if (index >= 0) {
|
|
537
|
+
const nthSel = `${classSel}:nth-of-type(${index + 1})`;
|
|
538
|
+
try {
|
|
539
|
+
if (el.ownerDocument.querySelectorAll(nthSel).length === 1) return nthSel;
|
|
540
|
+
} catch {
|
|
541
|
+
// fall through
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const parent = el.parentElement;
|
|
548
|
+
if (parent) {
|
|
549
|
+
const siblings = Array.from(parent.querySelectorAll(`:scope > ${tag}`));
|
|
550
|
+
const index = siblings.indexOf(el);
|
|
551
|
+
if (index >= 0) {
|
|
552
|
+
return `${tag}:nth-of-type(${index + 1})`;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return tag;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function baseInteractivityScore(
|
|
560
|
+
interactivity: EnrichedPageElement["interactivity"]
|
|
561
|
+
): number {
|
|
562
|
+
return interactivity === "static" ? BASE_SCORE_STATIC : BASE_SCORE_INTERACTIVE;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
interface ScoredCandidate {
|
|
566
|
+
el: HTMLElement;
|
|
567
|
+
domIndex: number;
|
|
568
|
+
enriched: EnrichedPageElement;
|
|
569
|
+
score: number;
|
|
570
|
+
formattingRule: ParseRule | null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function buildEnriched(
|
|
574
|
+
el: HTMLElement,
|
|
575
|
+
maxTextLength: number
|
|
576
|
+
): EnrichedPageElement {
|
|
577
|
+
const tag = el.tagName.toLowerCase();
|
|
578
|
+
const text = (el.textContent ?? "").trim().substring(0, maxTextLength);
|
|
579
|
+
return {
|
|
580
|
+
selector: generateStableSelector(el),
|
|
581
|
+
tagName: tag,
|
|
582
|
+
text,
|
|
583
|
+
role: el.getAttribute("role"),
|
|
584
|
+
interactivity: classifyInteractivity(el),
|
|
585
|
+
attributes: collectAttributes(el),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function scoreCandidate(
|
|
590
|
+
el: HTMLElement,
|
|
591
|
+
enriched: EnrichedPageElement,
|
|
592
|
+
rules: ParseRule[],
|
|
593
|
+
ctx: RuleScoringContext
|
|
594
|
+
): { score: number; formattingRule: ParseRule | null } {
|
|
595
|
+
let score = baseInteractivityScore(enriched.interactivity);
|
|
596
|
+
let formattingRule: ParseRule | null = null;
|
|
597
|
+
for (const rule of rules) {
|
|
598
|
+
const bonus = rule.scoreElement(el, enriched, ctx);
|
|
599
|
+
if (bonus > 0) {
|
|
600
|
+
score += bonus;
|
|
601
|
+
if (rule.formatSummary && !formattingRule) formattingRule = rule;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return { score, formattingRule };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function shouldSuppress(
|
|
608
|
+
kept: ScoredCandidate[],
|
|
609
|
+
cand: ScoredCandidate
|
|
610
|
+
): boolean {
|
|
611
|
+
for (const k of kept) {
|
|
612
|
+
if (cand.el === k.el) continue;
|
|
613
|
+
if (!k.formattingRule?.shouldSuppressDescendant) continue;
|
|
614
|
+
if (!k.el.contains(cand.el)) continue;
|
|
615
|
+
if (
|
|
616
|
+
k.formattingRule.shouldSuppressDescendant(
|
|
617
|
+
k.el,
|
|
618
|
+
cand.el,
|
|
619
|
+
cand.enriched
|
|
620
|
+
)
|
|
621
|
+
)
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function collectStructured(
|
|
628
|
+
cfg: ResolvedDomContextConfig,
|
|
629
|
+
rootEl: HTMLElement
|
|
630
|
+
): EnrichedPageElement[] {
|
|
631
|
+
const ctx: RuleScoringContext = {
|
|
632
|
+
doc: rootEl.ownerDocument,
|
|
633
|
+
maxTextLength: cfg.maxTextLength,
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
const seenSelectors = new Set<string>();
|
|
637
|
+
const raw: ScoredCandidate[] = [];
|
|
638
|
+
let domIndex = 0;
|
|
639
|
+
|
|
640
|
+
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT, null);
|
|
641
|
+
let node: Node | null = walker.currentNode;
|
|
642
|
+
|
|
643
|
+
while (node && raw.length < cfg.maxCandidates) {
|
|
644
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
645
|
+
const el = node as HTMLElement;
|
|
646
|
+
const tag = el.tagName.toLowerCase();
|
|
647
|
+
|
|
648
|
+
if (SKIP_TAGS.has(tag)) {
|
|
649
|
+
node = walker.nextNode();
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (cfg.excludeSelector) {
|
|
654
|
+
try {
|
|
655
|
+
if (el.closest(cfg.excludeSelector)) {
|
|
656
|
+
node = walker.nextNode();
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
} catch {
|
|
660
|
+
// invalid selector
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (cfg.visibleOnly && !isElementVisible(el)) {
|
|
665
|
+
node = walker.nextNode();
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const enriched = buildEnriched(el, cfg.maxTextLength);
|
|
670
|
+
const hasText = enriched.text.length > 0;
|
|
671
|
+
const hasMeaningfulAttrs =
|
|
672
|
+
Object.keys(enriched.attributes).length > 0 &&
|
|
673
|
+
!Object.keys(enriched.attributes).every((k) => k === "role");
|
|
674
|
+
|
|
675
|
+
if (!hasText && !hasMeaningfulAttrs) {
|
|
676
|
+
node = walker.nextNode();
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (seenSelectors.has(enriched.selector)) {
|
|
681
|
+
node = walker.nextNode();
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
seenSelectors.add(enriched.selector);
|
|
685
|
+
|
|
686
|
+
const { score, formattingRule } = scoreCandidate(
|
|
687
|
+
el,
|
|
688
|
+
enriched,
|
|
689
|
+
cfg.rules,
|
|
690
|
+
ctx
|
|
691
|
+
);
|
|
692
|
+
raw.push({ el, domIndex, enriched, score, formattingRule });
|
|
693
|
+
domIndex += 1;
|
|
694
|
+
}
|
|
695
|
+
node = walker.nextNode();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
raw.sort((a, b) => {
|
|
699
|
+
const sa = a.enriched.interactivity === "static" ? 1 : 0;
|
|
700
|
+
const sb = b.enriched.interactivity === "static" ? 1 : 0;
|
|
701
|
+
if (sa !== sb) return sa - sb;
|
|
702
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
703
|
+
return a.domIndex - b.domIndex;
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
const kept: ScoredCandidate[] = [];
|
|
707
|
+
for (const cand of raw) {
|
|
708
|
+
if (kept.length >= cfg.maxElements) break;
|
|
709
|
+
if (shouldSuppress(kept, cand)) continue;
|
|
710
|
+
kept.push(cand);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
kept.sort((a, b) => {
|
|
714
|
+
const sa = a.enriched.interactivity === "static" ? 1 : 0;
|
|
715
|
+
const sb = b.enriched.interactivity === "static" ? 1 : 0;
|
|
716
|
+
if (sa !== sb) return sa - sb;
|
|
717
|
+
if (sa === 1 && b.score !== a.score) return b.score - a.score;
|
|
718
|
+
return a.domIndex - b.domIndex;
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
return kept.map((c) => {
|
|
722
|
+
let formattedSummary: string | undefined;
|
|
723
|
+
if (c.formattingRule?.formatSummary) {
|
|
724
|
+
const line = c.formattingRule.formatSummary(c.el, c.enriched, ctx);
|
|
725
|
+
if (line) formattedSummary = line;
|
|
726
|
+
}
|
|
727
|
+
const out: EnrichedPageElement = { ...c.enriched };
|
|
728
|
+
if (formattedSummary) out.formattedSummary = formattedSummary;
|
|
729
|
+
return out;
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function collectSimple(
|
|
734
|
+
cfg: ResolvedDomContextConfig,
|
|
735
|
+
rootEl: HTMLElement
|
|
736
|
+
): EnrichedPageElement[] {
|
|
737
|
+
const elements: EnrichedPageElement[] = [];
|
|
738
|
+
const seenSelectors = new Set<string>();
|
|
739
|
+
|
|
740
|
+
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT, null);
|
|
741
|
+
let node: Node | null = walker.currentNode;
|
|
742
|
+
|
|
743
|
+
while (node && elements.length < cfg.maxElements) {
|
|
744
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
745
|
+
const el = node as HTMLElement;
|
|
746
|
+
const tag = el.tagName.toLowerCase();
|
|
747
|
+
|
|
748
|
+
if (SKIP_TAGS.has(tag)) {
|
|
749
|
+
node = walker.nextNode();
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (cfg.excludeSelector) {
|
|
754
|
+
try {
|
|
755
|
+
if (el.closest(cfg.excludeSelector)) {
|
|
756
|
+
node = walker.nextNode();
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
} catch {
|
|
760
|
+
// invalid selector
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (cfg.visibleOnly && !isElementVisible(el)) {
|
|
765
|
+
node = walker.nextNode();
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const enriched = buildEnriched(el, cfg.maxTextLength);
|
|
770
|
+
const hasText = enriched.text.length > 0;
|
|
771
|
+
const hasMeaningfulAttrs =
|
|
772
|
+
Object.keys(enriched.attributes).length > 0 &&
|
|
773
|
+
!Object.keys(enriched.attributes).every((k) => k === "role");
|
|
774
|
+
|
|
775
|
+
if (!hasText && !hasMeaningfulAttrs) {
|
|
776
|
+
node = walker.nextNode();
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (!seenSelectors.has(enriched.selector)) {
|
|
781
|
+
seenSelectors.add(enriched.selector);
|
|
782
|
+
elements.push(enriched);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
node = walker.nextNode();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const interactive: EnrichedPageElement[] = [];
|
|
789
|
+
const staticEls: EnrichedPageElement[] = [];
|
|
790
|
+
for (const el of elements) {
|
|
791
|
+
if (el.interactivity !== "static") interactive.push(el);
|
|
792
|
+
else staticEls.push(el);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return [...interactive, ...staticEls].slice(0, cfg.maxElements);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Collect enriched page context from the DOM.
|
|
800
|
+
*
|
|
801
|
+
* - **Default (structured):** walks up to `maxCandidates` nodes, scores with
|
|
802
|
+
* {@link defaultParseRules} (or `rules`), suppresses redundant descendants when a
|
|
803
|
+
* formatting rule matches, then keeps the top `maxElements` by score (DOM order tie-break).
|
|
804
|
+
* - **simple:** legacy path — stops once `maxElements` nodes are collected during traversal
|
|
805
|
+
* and sorts interactive before static.
|
|
806
|
+
*
|
|
807
|
+
* Pass `options: { mode: "simple" }` to disable rules. If `mode` is `simple` and `rules` is
|
|
808
|
+
* non-empty, rules are ignored and a console warning is emitted.
|
|
809
|
+
*/
|
|
810
|
+
export function collectEnrichedPageContext(
|
|
811
|
+
options: DomContextOptions = {}
|
|
812
|
+
): EnrichedPageElement[] {
|
|
813
|
+
const cfg = resolveDomContextConfig(options);
|
|
814
|
+
const rootEl = cfg.root ?? document.body;
|
|
815
|
+
if (!rootEl) return [];
|
|
816
|
+
|
|
817
|
+
if (cfg.mode === "simple") {
|
|
818
|
+
return collectSimple(cfg, rootEl);
|
|
819
|
+
}
|
|
820
|
+
return collectStructured(cfg, rootEl);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const TEXT_PREVIEW_LEN = 100;
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Format enriched page elements as a structured string for LLM consumption.
|
|
827
|
+
* When `mode` is structured (default) and elements include {@link EnrichedPageElement.formattedSummary},
|
|
828
|
+
* those render under **Structured summaries** before the usual interactivity groups.
|
|
829
|
+
*/
|
|
830
|
+
export function formatEnrichedContext(
|
|
831
|
+
elements: EnrichedPageElement[],
|
|
832
|
+
options: FormatEnrichedContextOptions = {}
|
|
833
|
+
): string {
|
|
834
|
+
if (elements.length === 0) {
|
|
835
|
+
return "No page elements found.";
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const mode: DomContextMode = options.mode ?? "structured";
|
|
839
|
+
const sections: string[] = [];
|
|
840
|
+
|
|
841
|
+
if (mode === "structured") {
|
|
842
|
+
const summaries = elements
|
|
843
|
+
.map((el) => el.formattedSummary)
|
|
844
|
+
.filter((s): s is string => !!s && s.length > 0);
|
|
845
|
+
if (summaries.length > 0) {
|
|
846
|
+
sections.push(
|
|
847
|
+
`Structured summaries:\n${summaries.map((s) => `- ${s.split("\n").join("\n ")}`).join("\n")}`
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const groups: Record<string, EnrichedPageElement[]> = {
|
|
853
|
+
clickable: [],
|
|
854
|
+
navigable: [],
|
|
855
|
+
input: [],
|
|
856
|
+
static: [],
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
for (const el of elements) {
|
|
860
|
+
if (mode === "structured" && el.formattedSummary) continue;
|
|
861
|
+
groups[el.interactivity].push(el);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (groups.clickable.length > 0) {
|
|
865
|
+
const lines = groups.clickable.map(
|
|
866
|
+
(el) =>
|
|
867
|
+
`- ${el.selector}: "${el.text.substring(0, TEXT_PREVIEW_LEN)}" (clickable)`
|
|
868
|
+
);
|
|
869
|
+
sections.push(`Interactive elements:\n${lines.join("\n")}`);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (groups.navigable.length > 0) {
|
|
873
|
+
const lines = groups.navigable.map(
|
|
874
|
+
(el) =>
|
|
875
|
+
`- ${el.selector}${el.attributes.href ? `[href="${el.attributes.href}"]` : ""}: "${el.text.substring(0, TEXT_PREVIEW_LEN)}" (navigable)`
|
|
876
|
+
);
|
|
877
|
+
sections.push(`Navigation links:\n${lines.join("\n")}`);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (groups.input.length > 0) {
|
|
881
|
+
const lines = groups.input.map(
|
|
882
|
+
(el) =>
|
|
883
|
+
`- ${el.selector}${el.attributes.type ? `[type="${el.attributes.type}"]` : ""}: "${el.text.substring(0, TEXT_PREVIEW_LEN)}" (input)`
|
|
884
|
+
);
|
|
885
|
+
sections.push(`Form inputs:\n${lines.join("\n")}`);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (groups.static.length > 0) {
|
|
889
|
+
const lines = groups.static.map(
|
|
890
|
+
(el) => `- ${el.selector}: "${el.text.substring(0, TEXT_PREVIEW_LEN)}"`
|
|
891
|
+
);
|
|
892
|
+
sections.push(`Content:\n${lines.join("\n")}`);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return sections.join("\n\n");
|
|
896
|
+
}
|