@hexclave/shared 1.0.14 → 1.0.16
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/dist/apps/apps-config.d.ts +6 -0
- package/dist/apps/apps-config.js +6 -0
- package/dist/apps/apps-config.js.map +1 -1
- package/dist/config/schema.d.ts +107 -107
- package/dist/config-authoring.d.ts.map +1 -1
- package/dist/config-authoring.js.map +1 -1
- package/dist/config-authoring.test.js +1 -1
- package/dist/config-authoring.test.js.map +1 -1
- package/dist/config-rendering.d.ts +2 -2
- package/dist/config-rendering.js +16 -0
- package/dist/config-rendering.js.map +1 -1
- package/dist/esm/apps/apps-config.d.ts +6 -0
- package/dist/esm/apps/apps-config.js +6 -0
- package/dist/esm/apps/apps-config.js.map +1 -1
- package/dist/esm/config/schema.d.ts +107 -107
- package/dist/esm/config-authoring.d.ts.map +1 -1
- package/dist/esm/config-authoring.js.map +1 -1
- package/dist/esm/config-authoring.test.js +1 -1
- package/dist/esm/config-authoring.test.js.map +1 -1
- package/dist/esm/config-rendering.d.ts +2 -2
- package/dist/esm/config-rendering.js +16 -2
- package/dist/esm/config-rendering.js.map +1 -1
- package/dist/esm/hexclave-config-file.d.ts +24 -1
- package/dist/esm/hexclave-config-file.d.ts.map +1 -1
- package/dist/esm/hexclave-config-file.js +69 -2
- package/dist/esm/hexclave-config-file.js.map +1 -1
- package/dist/esm/interface/admin-interface.d.ts +19 -1
- package/dist/esm/interface/admin-interface.d.ts.map +1 -1
- package/dist/esm/interface/admin-interface.js +14 -0
- package/dist/esm/interface/admin-interface.js.map +1 -1
- package/dist/esm/interface/admin-metrics.d.ts +115 -21
- package/dist/esm/interface/admin-metrics.d.ts.map +1 -1
- package/dist/esm/interface/admin-metrics.js +61 -1
- package/dist/esm/interface/admin-metrics.js.map +1 -1
- package/dist/esm/interface/conversations.d.ts +42 -42
- package/dist/esm/interface/crud/current-user.d.ts +15 -15
- package/dist/esm/interface/crud/email-outbox.d.ts +220 -220
- package/dist/esm/interface/crud/invoices.d.ts +2 -2
- package/dist/esm/interface/crud/products.d.ts +17 -17
- package/dist/esm/interface/crud/products.d.ts.map +1 -1
- package/dist/esm/interface/crud/project-api-keys.d.ts +4 -4
- package/dist/esm/interface/crud/projects.d.ts +59 -59
- package/dist/esm/interface/crud/team-member-profiles.d.ts +26 -26
- package/dist/esm/interface/crud/transactions.d.ts +15 -15
- package/dist/esm/interface/crud/transactions.d.ts.map +1 -1
- package/dist/esm/interface/crud/users.d.ts +18 -18
- package/dist/esm/interface/webhooks.d.ts +4 -4
- package/dist/esm/known-errors.d.ts +5 -5
- package/dist/esm/schema-fields.d.ts +8 -8
- package/dist/esm/sessions.d.ts +7 -7
- package/dist/esm/utils/analytics-clickmap-overlay.d.ts +18 -0
- package/dist/esm/utils/analytics-clickmap-overlay.d.ts.map +1 -0
- package/dist/esm/utils/analytics-clickmap-overlay.js +19 -0
- package/dist/esm/utils/analytics-clickmap-overlay.js.map +1 -0
- package/dist/esm/utils/dev-tool.d.ts +31 -0
- package/dist/esm/utils/dev-tool.d.ts.map +1 -0
- package/dist/esm/utils/dev-tool.js +32 -0
- package/dist/esm/utils/dev-tool.js.map +1 -0
- package/dist/esm/utils/dom.d.ts +7 -1
- package/dist/esm/utils/dom.d.ts.map +1 -1
- package/dist/esm/utils/dom.js +20 -1
- package/dist/esm/utils/dom.js.map +1 -1
- package/dist/esm/utils/elements-chain.d.ts +38 -0
- package/dist/esm/utils/elements-chain.d.ts.map +1 -0
- package/dist/esm/utils/elements-chain.js +297 -0
- package/dist/esm/utils/elements-chain.js.map +1 -0
- package/dist/hexclave-config-file.d.ts +24 -1
- package/dist/hexclave-config-file.d.ts.map +1 -1
- package/dist/hexclave-config-file.js +71 -1
- package/dist/hexclave-config-file.js.map +1 -1
- package/dist/interface/admin-interface.d.ts +19 -1
- package/dist/interface/admin-interface.d.ts.map +1 -1
- package/dist/interface/admin-interface.js +14 -0
- package/dist/interface/admin-interface.js.map +1 -1
- package/dist/interface/admin-metrics.d.ts +115 -21
- package/dist/interface/admin-metrics.d.ts.map +1 -1
- package/dist/interface/admin-metrics.js +65 -0
- package/dist/interface/admin-metrics.js.map +1 -1
- package/dist/interface/conversations.d.ts +42 -42
- package/dist/interface/crud/current-user.d.ts +15 -15
- package/dist/interface/crud/email-outbox.d.ts +220 -220
- package/dist/interface/crud/invoices.d.ts +2 -2
- package/dist/interface/crud/products.d.ts +17 -17
- package/dist/interface/crud/products.d.ts.map +1 -1
- package/dist/interface/crud/project-api-keys.d.ts +4 -4
- package/dist/interface/crud/projects.d.ts +59 -59
- package/dist/interface/crud/team-member-profiles.d.ts +26 -26
- package/dist/interface/crud/transactions.d.ts +15 -15
- package/dist/interface/crud/transactions.d.ts.map +1 -1
- package/dist/interface/crud/users.d.ts +18 -18
- package/dist/interface/webhooks.d.ts +4 -4
- package/dist/known-errors.d.ts +5 -5
- package/dist/schema-fields.d.ts +8 -8
- package/dist/sessions.d.ts +7 -7
- package/dist/utils/analytics-clickmap-overlay.d.ts +18 -0
- package/dist/utils/analytics-clickmap-overlay.d.ts.map +1 -0
- package/dist/utils/analytics-clickmap-overlay.js +23 -0
- package/dist/utils/analytics-clickmap-overlay.js.map +1 -0
- package/dist/utils/dev-tool.d.ts +31 -0
- package/dist/utils/dev-tool.d.ts.map +1 -0
- package/dist/utils/dev-tool.js +37 -0
- package/dist/utils/dev-tool.js.map +1 -0
- package/dist/utils/dom.d.ts +7 -1
- package/dist/utils/dom.d.ts.map +1 -1
- package/dist/utils/dom.js +20 -0
- package/dist/utils/dom.js.map +1 -1
- package/dist/utils/elements-chain.d.ts +38 -0
- package/dist/utils/elements-chain.d.ts.map +1 -0
- package/dist/utils/elements-chain.js +304 -0
- package/dist/utils/elements-chain.js.map +1 -0
- package/package.json +1 -1
- package/src/apps/apps-config.ts +6 -0
- package/src/config-authoring.test.ts +1 -1
- package/src/config-authoring.ts +6 -6
- package/src/config-rendering.ts +24 -2
- package/src/hexclave-config-file.ts +90 -1
- package/src/interface/admin-interface.ts +43 -1
- package/src/interface/admin-metrics.ts +92 -4
- package/src/utils/analytics-clickmap-overlay.tsx +15 -0
- package/src/utils/dev-tool.tsx +30 -0
- package/src/utils/dom.tsx +42 -0
- package/src/utils/elements-chain.tsx +349 -0
|
@@ -140,9 +140,9 @@ export const MetricsAnalyticsOverviewSchema = yupObject({
|
|
|
140
140
|
daily_bounce_rate: yupArray(MetricsDataPointSchema).optional().default([]),
|
|
141
141
|
daily_avg_session_seconds: yupArray(MetricsDataPointSchema).optional().default([]),
|
|
142
142
|
// User-Agent-derived breakdowns for the analytics overview. Computed from the
|
|
143
|
-
// `data.user_agent` blob on `$page-view` events
|
|
144
|
-
//
|
|
145
|
-
//
|
|
143
|
+
// `data.user_agent` blob on `$page-view` events, captured client-side.
|
|
144
|
+
// Optional + default-[] for one release cycle so older clients / servers
|
|
145
|
+
// without UA capture don't fail validation.
|
|
146
146
|
top_browsers: yupArray(MetricsNamedCountSchema).optional().default([]),
|
|
147
147
|
top_operating_systems: yupArray(MetricsNamedCountSchema).optional().default([]),
|
|
148
148
|
top_devices: yupArray(MetricsNamedCountSchema).optional().default([]),
|
|
@@ -172,13 +172,77 @@ export const MetricsRecentUserSchema = yupObject({
|
|
|
172
172
|
last_active_at_millis: yupNumber().nullable().defined(),
|
|
173
173
|
}).noUnknown(false).defined();
|
|
174
174
|
|
|
175
|
-
// Per-user activity
|
|
175
|
+
// Per-user activity clickmap — a simple list of daily event counts for a single
|
|
176
176
|
// user. Backed by ClickHouse `analytics_internal.events` filtered by user_id,
|
|
177
177
|
// project_id, and branch_id. See `/internal/user-activity` on the backend.
|
|
178
178
|
export const UserActivityResponseBodySchema = yupObject({
|
|
179
179
|
data_points: MetricsDataPointsSchema,
|
|
180
180
|
}).defined();
|
|
181
181
|
|
|
182
|
+
export const AnalyticsClickmapKindSchema = yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined();
|
|
183
|
+
export const AnalyticsClickmapDeviceSchema = yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).defined();
|
|
184
|
+
|
|
185
|
+
export const AnalyticsClickmapTokenResponseBodySchema = yupObject({
|
|
186
|
+
token: yupString().defined(),
|
|
187
|
+
origin: yupString().defined(),
|
|
188
|
+
expires_at_millis: yupNumber().integer().defined(),
|
|
189
|
+
}).defined();
|
|
190
|
+
|
|
191
|
+
export const AnalyticsClickmapCellSchema = yupObject({
|
|
192
|
+
weekday: yupNumber().integer().min(1).max(7).defined(),
|
|
193
|
+
hour: yupNumber().integer().min(0).max(23).defined(),
|
|
194
|
+
value: yupNumber().integer().defined(),
|
|
195
|
+
}).defined();
|
|
196
|
+
|
|
197
|
+
export const AnalyticsClickmapResponseBodySchema = yupObject({
|
|
198
|
+
kind: AnalyticsClickmapKindSchema,
|
|
199
|
+
cells: yupArray(AnalyticsClickmapCellSchema).defined(),
|
|
200
|
+
// Fraction of source rows the result was computed from (1 = full scan).
|
|
201
|
+
// Returned counts are pre-scaled by 1/sampling.
|
|
202
|
+
sampling: yupNumber().min(0).max(1).optional().default(1),
|
|
203
|
+
routes: yupArray(yupObject({
|
|
204
|
+
path: yupString().defined(),
|
|
205
|
+
clicks: yupNumber().integer().defined(),
|
|
206
|
+
users: yupNumber().integer().defined(),
|
|
207
|
+
replays: yupNumber().integer().defined(),
|
|
208
|
+
}).defined()).optional().default([]),
|
|
209
|
+
users: yupArray(yupObject({
|
|
210
|
+
id: yupString().defined(),
|
|
211
|
+
display_name: yupString().nullable().defined(),
|
|
212
|
+
primary_email: yupString().nullable().defined(),
|
|
213
|
+
profile_image_url: yupString().nullable().defined(),
|
|
214
|
+
clicks: yupNumber().integer().defined(),
|
|
215
|
+
replays: yupNumber().integer().defined(),
|
|
216
|
+
last_event_at_millis: yupNumber().defined(),
|
|
217
|
+
}).defined()).optional().default([]),
|
|
218
|
+
replays: yupArray(yupObject({
|
|
219
|
+
id: yupString().defined(),
|
|
220
|
+
user_id: yupString().nullable().defined(),
|
|
221
|
+
route_path: yupString().nullable().defined(),
|
|
222
|
+
viewport_width: yupNumber().integer().nullable().defined(),
|
|
223
|
+
viewport_height: yupNumber().integer().nullable().defined(),
|
|
224
|
+
clicks: yupNumber().integer().defined(),
|
|
225
|
+
last_event_at_millis: yupNumber().defined(),
|
|
226
|
+
}).defined()).optional().default([]),
|
|
227
|
+
selectors: yupArray(yupObject({
|
|
228
|
+
selector: yupString().defined(),
|
|
229
|
+
clicks: yupNumber().integer().defined(),
|
|
230
|
+
}).defined()).optional().default([]),
|
|
231
|
+
// PostHog-style aggregated element identities. Resilient to DOM drift
|
|
232
|
+
// because the chain encodes ancestor tags/classes/attrs/text rather than
|
|
233
|
+
// a positional CSS selector.
|
|
234
|
+
elements: yupArray(yupObject({
|
|
235
|
+
elements_chain: yupString().defined(),
|
|
236
|
+
elements_text: yupString().defined(),
|
|
237
|
+
tag_name: yupString().defined(),
|
|
238
|
+
href: yupString().nullable().defined(),
|
|
239
|
+
clicks: yupNumber().integer().defined(),
|
|
240
|
+
// Optional for one release cycle so new overlay clients can validate
|
|
241
|
+
// responses from older servers that only returned aggregate click counts.
|
|
242
|
+
dead_clicks: yupNumber().integer().optional().default(0),
|
|
243
|
+
}).defined()).optional().default([]),
|
|
244
|
+
}).defined();
|
|
245
|
+
|
|
182
246
|
// Recent "currently live" users keyed by ISO country code. Populated by
|
|
183
247
|
// joining a bounded ClickHouse selection from the live `$token-refresh` window
|
|
184
248
|
// with the corresponding Prisma profile rows, so the overview globe can render
|
|
@@ -243,3 +307,27 @@ export type MetricsRecentUser = yup.InferType<typeof MetricsRecentUserSchema>;
|
|
|
243
307
|
export type MetricsResponse = yup.InferType<typeof MetricsResponseBodySchema>;
|
|
244
308
|
export type MetricsUserCounts = yup.InferType<typeof MetricsUserCountsSchema>;
|
|
245
309
|
export type UserActivityResponse = yup.InferType<typeof UserActivityResponseBodySchema>;
|
|
310
|
+
export type AnalyticsClickmapKind = yup.InferType<typeof AnalyticsClickmapKindSchema>;
|
|
311
|
+
export type AnalyticsClickmapDevice = yup.InferType<typeof AnalyticsClickmapDeviceSchema>;
|
|
312
|
+
export type AnalyticsClickmapCell = yup.InferType<typeof AnalyticsClickmapCellSchema>;
|
|
313
|
+
export type AnalyticsClickmapResponse = yup.InferType<typeof AnalyticsClickmapResponseBodySchema>;
|
|
314
|
+
export type AnalyticsClickmapTokenResponse = yup.InferType<typeof AnalyticsClickmapTokenResponseBodySchema>;
|
|
315
|
+
|
|
316
|
+
// Single (camelCase) options shape for the clickmap SDK surface — shared by the
|
|
317
|
+
// StackAdminApp interface and its implementation so the two can't drift. The
|
|
318
|
+
// HexclaveAdminInterface transport layer maps these to the snake_case request body.
|
|
319
|
+
export type AnalyticsClickmapOptions = {
|
|
320
|
+
kind: AnalyticsClickmapKind,
|
|
321
|
+
memberUserIds?: string[],
|
|
322
|
+
routePath?: string,
|
|
323
|
+
routeRegex?: string,
|
|
324
|
+
urlPattern?: string,
|
|
325
|
+
userId?: string,
|
|
326
|
+
replayId?: string,
|
|
327
|
+
device?: AnalyticsClickmapDevice,
|
|
328
|
+
viewportWidthMin?: number,
|
|
329
|
+
viewportWidthMax?: number,
|
|
330
|
+
sampling?: number,
|
|
331
|
+
since: string,
|
|
332
|
+
until: string,
|
|
333
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol for handing a clickmap overlay token from the dashboard to the
|
|
3
|
+
* in-page dev tool via `sessionStorage` + a window event.
|
|
4
|
+
*
|
|
5
|
+
* The token is a self-describing JWT: its payload already carries the
|
|
6
|
+
* `project_id` and `origin` it was minted for, so the reader derives both from
|
|
7
|
+
* the token itself and the writer only has to hand over a single value. The
|
|
8
|
+
* dashboard (writer) and the dev tool (reader) live in different packages but
|
|
9
|
+
* must agree on these exact names — this module is the single source of truth so
|
|
10
|
+
* they can never silently desync.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY = "hexclave-clickmap-token";
|
|
14
|
+
export const CLICKMAP_OVERLAY_RESUME_STORAGE_KEY = "hexclave-clickmap-resume";
|
|
15
|
+
export const CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT = "hexclave:clickmap-token-updated";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared identity of the Hexclave in-page dev tool / clickmap overlay.
|
|
3
|
+
*
|
|
4
|
+
* These constants are the single source of truth for "is this DOM / event /
|
|
5
|
+
* stored click part of the dev tool itself?". They are consumed across package
|
|
6
|
+
* boundaries:
|
|
7
|
+
* - the dev tool mounts its root element with {@link DEV_TOOL_ROOT_ID} and
|
|
8
|
+
* prefixes every generated class with {@link DEV_TOOL_CLASS_PREFIX};
|
|
9
|
+
* - the event tracker uses them to skip self-clicks at ingest;
|
|
10
|
+
* - the backend clickmap query uses them to filter dev-tool clicks out of
|
|
11
|
+
* aggregate clickmaps server-side.
|
|
12
|
+
*
|
|
13
|
+
* Keep them here so a rename can never silently desync the SQL filter from the
|
|
14
|
+
* actual DOM identity.
|
|
15
|
+
*/
|
|
16
|
+
export const DEV_TOOL_ROOT_ID = "__hexclave-dev-tool-root";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Root element id of the standalone clickmap overlay. The clickmap is an
|
|
20
|
+
* independent feature with its own mount (it must survive the dev tool being
|
|
21
|
+
* removed), so it gets its own root — but its self-clicks need the exact same
|
|
22
|
+
* ingest/query exclusions as the dev tool's.
|
|
23
|
+
*/
|
|
24
|
+
export const CLICKMAP_ROOT_ID = "__hexclave-clickmap-root";
|
|
25
|
+
|
|
26
|
+
/** Prefix applied to every class/generated id the dev tool renders. */
|
|
27
|
+
export const DEV_TOOL_CLASS_PREFIX = "sdt-";
|
|
28
|
+
|
|
29
|
+
/** Legacy class marker still present on older dev-tool builds. */
|
|
30
|
+
export const DEV_TOOL_LEGACY_CLASS = "stack-devtool";
|
package/src/utils/dom.tsx
CHANGED
|
@@ -5,3 +5,45 @@ export function hasClickableParent(element: HTMLElement): boolean {
|
|
|
5
5
|
|
|
6
6
|
return hasClickableParent(element.parentElement);
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Escape a string so it is safe to use as a CSS identifier (id/class) inside a selector.
|
|
11
|
+
* Prefers the native `CSS.escape` when available, falling back to a conservative
|
|
12
|
+
* backslash-escape for non-DOM environments (SSR, tests, older runtimes).
|
|
13
|
+
*/
|
|
14
|
+
export function cssEscapeIdent(value: string): string {
|
|
15
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
16
|
+
return CSS.escape(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let escaped = "";
|
|
20
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
21
|
+
const char = value.charAt(i);
|
|
22
|
+
const codeUnit = value.charCodeAt(i);
|
|
23
|
+
|
|
24
|
+
if (codeUnit === 0x0000) {
|
|
25
|
+
escaped += "\uFFFD";
|
|
26
|
+
} else if (
|
|
27
|
+
(codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
|
|
28
|
+
codeUnit === 0x007f ||
|
|
29
|
+
(i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
|
|
30
|
+
(i === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && value.charCodeAt(0) === 0x002d)
|
|
31
|
+
) {
|
|
32
|
+
escaped += `\\${codeUnit.toString(16)} `;
|
|
33
|
+
} else if (i === 0 && codeUnit === 0x002d && value.length === 1) {
|
|
34
|
+
escaped += "\\-";
|
|
35
|
+
} else if (
|
|
36
|
+
codeUnit >= 0x0080 ||
|
|
37
|
+
codeUnit === 0x002d ||
|
|
38
|
+
codeUnit === 0x005f ||
|
|
39
|
+
(codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
|
|
40
|
+
(codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
|
|
41
|
+
(codeUnit >= 0x0061 && codeUnit <= 0x007a)
|
|
42
|
+
) {
|
|
43
|
+
escaped += char;
|
|
44
|
+
} else {
|
|
45
|
+
escaped += `\\${char}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return escaped;
|
|
49
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHog-style `elements_chain` format — the single owner of both halves of the
|
|
3
|
+
* contract. The event tracker {@link buildElementsChain serializes} a clicked
|
|
4
|
+
* element (and its ancestors) into a string; the clickmap overlay
|
|
5
|
+
* {@link parseElementsChain parses} that string back into structured segments so
|
|
6
|
+
* it can re-locate the element in a live DOM.
|
|
7
|
+
*
|
|
8
|
+
* Encode and decode MUST round-trip exactly, which is why they live together:
|
|
9
|
+
* the escaping applied here on the write side is reversed by the parser below,
|
|
10
|
+
* and a single round-trip test in `elements-chain.test.tsx` guards the pair.
|
|
11
|
+
*
|
|
12
|
+
* Segment shape (leaf-first, joined by `;`):
|
|
13
|
+
* tag.class1.class2:nth-child="2":nth-of-type="1":text="Save":attr__id="x":href="..."
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type ElementsChainSegment = {
|
|
17
|
+
tag: string,
|
|
18
|
+
classes: string[],
|
|
19
|
+
attrs: Record<string, string>,
|
|
20
|
+
text: string | null,
|
|
21
|
+
nthChild: number | null,
|
|
22
|
+
nthOfType: number | null,
|
|
23
|
+
href: string | null,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const ELEMENTS_CHAIN_MAX_DEPTH = 8;
|
|
27
|
+
export const ELEMENTS_CHAIN_TEXT_MAX = 80;
|
|
28
|
+
export const ELEMENTS_CHAIN_ATTR_MAX = 200;
|
|
29
|
+
|
|
30
|
+
// Attributes we serialise into elements_chain. Mirrors the set PostHog persists:
|
|
31
|
+
// stable identifiers (id, data-testid), semantics (role, type, name, aria-label),
|
|
32
|
+
// and a few we expect downstream tooling to want to match against.
|
|
33
|
+
export const ELEMENTS_CHAIN_ATTRS = [
|
|
34
|
+
"id",
|
|
35
|
+
"data-testid",
|
|
36
|
+
"data-test-id",
|
|
37
|
+
"data-hexclave-id",
|
|
38
|
+
"name",
|
|
39
|
+
"type",
|
|
40
|
+
"role",
|
|
41
|
+
"aria-label",
|
|
42
|
+
"placeholder",
|
|
43
|
+
"title",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Serialization (DOM element -> elements_chain string)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function escapeElementsChainValue(value: string): string {
|
|
51
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Class tokens are written into the unquoted, dot-joined prefix of a segment, so
|
|
55
|
+
// any "." or ":" inside a class (e.g. Tailwind variants like `md:hover:bg-blue-500`
|
|
56
|
+
// or arbitrary values like `w-[1.5rem]`) must be escaped to round-trip through the
|
|
57
|
+
// parser, which splits the prefix on unescaped "." and the segment on unescaped ":".
|
|
58
|
+
function escapeElementsChainClass(value: string): string {
|
|
59
|
+
return value.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/:/g, "\\:");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getElementClasses(element: Element): string[] {
|
|
63
|
+
const className = (element as HTMLElement).className;
|
|
64
|
+
if (typeof className !== "string" || className.trim() === "") {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return className.trim().split(/\s+/).filter(Boolean).slice(0, 4);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getNthChildIndex(element: Element): number | null {
|
|
71
|
+
const parent = element.parentElement;
|
|
72
|
+
if (parent == null) return null;
|
|
73
|
+
const index = Array.prototype.indexOf.call(parent.children, element);
|
|
74
|
+
return index >= 0 ? index + 1 : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getNthOfTypeIndex(element: Element): number | null {
|
|
78
|
+
const parent = element.parentElement;
|
|
79
|
+
if (parent == null) return null;
|
|
80
|
+
const tagName = element.tagName;
|
|
81
|
+
const siblings = Array.from(parent.children).filter((child) => child.tagName === tagName);
|
|
82
|
+
if (siblings.length <= 1) return null;
|
|
83
|
+
const index = siblings.indexOf(element);
|
|
84
|
+
return index >= 0 ? index + 1 : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function serializeElementsChainSegment(element: Element): string {
|
|
88
|
+
const parts: string[] = [];
|
|
89
|
+
parts.push(element.tagName.toLowerCase());
|
|
90
|
+
const classes = getElementClasses(element);
|
|
91
|
+
if (classes.length > 0) {
|
|
92
|
+
parts.push(`.${classes.map(escapeElementsChainClass).join(".")}`);
|
|
93
|
+
}
|
|
94
|
+
const text = element.textContent.trim().replace(/\s+/g, " ").slice(0, ELEMENTS_CHAIN_TEXT_MAX);
|
|
95
|
+
const nthChild = getNthChildIndex(element);
|
|
96
|
+
const nthOfType = getNthOfTypeIndex(element);
|
|
97
|
+
const attrPairs: string[] = [];
|
|
98
|
+
if (nthChild != null) attrPairs.push(`nth-child="${nthChild}"`);
|
|
99
|
+
if (nthOfType != null) attrPairs.push(`nth-of-type="${nthOfType}"`);
|
|
100
|
+
if (text !== "") attrPairs.push(`text="${escapeElementsChainValue(text)}"`);
|
|
101
|
+
for (const attrName of ELEMENTS_CHAIN_ATTRS) {
|
|
102
|
+
const value = element.getAttribute(attrName);
|
|
103
|
+
if (value == null || value === "") continue;
|
|
104
|
+
attrPairs.push(`attr__${attrName}="${escapeElementsChainValue(value.slice(0, ELEMENTS_CHAIN_ATTR_MAX))}"`);
|
|
105
|
+
}
|
|
106
|
+
if (element.tagName === "A") {
|
|
107
|
+
const href = element.getAttribute("href");
|
|
108
|
+
if (href != null && href !== "") {
|
|
109
|
+
attrPairs.push(`href="${escapeElementsChainValue(href.slice(0, ELEMENTS_CHAIN_ATTR_MAX))}"`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (attrPairs.length > 0) {
|
|
113
|
+
parts.push(`:${attrPairs.join(":")}`);
|
|
114
|
+
}
|
|
115
|
+
return parts.join("");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Serialise a clicked element and up to {@link ELEMENTS_CHAIN_MAX_DEPTH}
|
|
120
|
+
* ancestors (leaf-first) into an `elements_chain` string.
|
|
121
|
+
*/
|
|
122
|
+
export function buildElementsChain(element: Element): string {
|
|
123
|
+
const segments: string[] = [];
|
|
124
|
+
let current: Element | null = element;
|
|
125
|
+
let depth = 0;
|
|
126
|
+
while (current != null && depth < ELEMENTS_CHAIN_MAX_DEPTH && current !== document.documentElement) {
|
|
127
|
+
segments.push(serializeElementsChainSegment(current));
|
|
128
|
+
current = current.parentElement;
|
|
129
|
+
depth += 1;
|
|
130
|
+
}
|
|
131
|
+
return segments.join(";");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Parsing (elements_chain string -> structured segments)
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
// Split a string on unescaped occurrences of `.`, unescaping `\.`, `\:` and `\\`
|
|
139
|
+
// back to their literal characters. Reverses `escapeElementsChainClass`.
|
|
140
|
+
function splitEscapedDots(input: string): string[] {
|
|
141
|
+
const out: string[] = [];
|
|
142
|
+
let cur = '';
|
|
143
|
+
for (let i = 0; i < input.length; i++) {
|
|
144
|
+
const ch = input[i];
|
|
145
|
+
if (ch === '\\' && i + 1 < input.length) {
|
|
146
|
+
cur += input[i + 1];
|
|
147
|
+
i += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (ch === '.') {
|
|
151
|
+
out.push(cur);
|
|
152
|
+
cur = '';
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
cur += ch;
|
|
156
|
+
}
|
|
157
|
+
out.push(cur);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type ElementsChainAttrResult = {
|
|
162
|
+
nthChild?: number,
|
|
163
|
+
nthOfType?: number,
|
|
164
|
+
text?: string,
|
|
165
|
+
href?: string,
|
|
166
|
+
attrKey?: string,
|
|
167
|
+
attrValue?: string,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
function applyElementsChainAttr(key: string, value: string): ElementsChainAttrResult {
|
|
171
|
+
if (key === 'nth-child') {
|
|
172
|
+
const n = Number.parseInt(value, 10);
|
|
173
|
+
return Number.isFinite(n) ? { nthChild: n } : {};
|
|
174
|
+
}
|
|
175
|
+
if (key === 'nth-of-type') {
|
|
176
|
+
const n = Number.parseInt(value, 10);
|
|
177
|
+
return Number.isFinite(n) ? { nthOfType: n } : {};
|
|
178
|
+
}
|
|
179
|
+
if (key === 'text') {
|
|
180
|
+
return { text: value };
|
|
181
|
+
}
|
|
182
|
+
if (key === 'href') {
|
|
183
|
+
return { href: value, attrKey: key, attrValue: value };
|
|
184
|
+
}
|
|
185
|
+
if (key.startsWith('attr__')) {
|
|
186
|
+
return { attrKey: key.slice('attr__'.length), attrValue: value };
|
|
187
|
+
}
|
|
188
|
+
return { attrKey: key, attrValue: value };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseElementsChainSegment(segment: string): ElementsChainSegment | null {
|
|
192
|
+
const trimmed = segment.trim();
|
|
193
|
+
if (trimmed === '') return null;
|
|
194
|
+
|
|
195
|
+
// Find first ':' at top level — separates tag/classes prefix from attribute pairs.
|
|
196
|
+
let prefixEnd = trimmed.length;
|
|
197
|
+
let inQuotes = false;
|
|
198
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
199
|
+
const ch = trimmed[i];
|
|
200
|
+
if (ch === '\\' && i + 1 < trimmed.length) {
|
|
201
|
+
i += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (ch === '"') {
|
|
205
|
+
inQuotes = !inQuotes;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (ch === ':' && !inQuotes) {
|
|
209
|
+
prefixEnd = i;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const prefix = trimmed.slice(0, prefixEnd);
|
|
215
|
+
const rest = trimmed.slice(prefixEnd);
|
|
216
|
+
const prefixParts = splitEscapedDots(prefix);
|
|
217
|
+
const tag = prefixParts[0].trim().toLowerCase();
|
|
218
|
+
if (tag === '') return null;
|
|
219
|
+
const classes = prefixParts.slice(1).map((c) => c.trim()).filter((c) => c !== '');
|
|
220
|
+
|
|
221
|
+
const attrs: Record<string, string> = {};
|
|
222
|
+
let nthChild: number | null = null;
|
|
223
|
+
let nthOfType: number | null = null;
|
|
224
|
+
let text: string | null = null;
|
|
225
|
+
let href: string | null = null;
|
|
226
|
+
|
|
227
|
+
// Parse :key="value" pairs from rest.
|
|
228
|
+
let i = 0;
|
|
229
|
+
while (i < rest.length) {
|
|
230
|
+
if (rest[i] !== ':') {
|
|
231
|
+
i += 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
i += 1; // skip ':'
|
|
235
|
+
// read key up to '='
|
|
236
|
+
let keyEnd = i;
|
|
237
|
+
while (keyEnd < rest.length && rest[keyEnd] !== '=' && rest[keyEnd] !== ':') keyEnd += 1;
|
|
238
|
+
const key = rest.slice(i, keyEnd).trim();
|
|
239
|
+
if (keyEnd >= rest.length || rest[keyEnd] !== '=') {
|
|
240
|
+
i = keyEnd;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
let valStart = keyEnd + 1;
|
|
244
|
+
if (rest[valStart] !== '"') {
|
|
245
|
+
// unquoted — read until next ':' at top level
|
|
246
|
+
let end = valStart;
|
|
247
|
+
while (end < rest.length && rest[end] !== ':') end += 1;
|
|
248
|
+
const value = rest.slice(valStart, end);
|
|
249
|
+
const result = applyElementsChainAttr(key, value);
|
|
250
|
+
if (result.nthChild != null) nthChild = result.nthChild;
|
|
251
|
+
if (result.nthOfType != null) nthOfType = result.nthOfType;
|
|
252
|
+
if (result.text != null) text = result.text;
|
|
253
|
+
if (result.href != null) href = result.href;
|
|
254
|
+
if (result.attrKey != null) attrs[result.attrKey] = result.attrValue ?? '';
|
|
255
|
+
i = end;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// quoted value — find unescaped closing quote
|
|
259
|
+
valStart += 1;
|
|
260
|
+
let end = valStart;
|
|
261
|
+
let value = '';
|
|
262
|
+
while (end < rest.length) {
|
|
263
|
+
const ch = rest[end];
|
|
264
|
+
if (ch === '\\' && end + 1 < rest.length) {
|
|
265
|
+
const next = rest[end + 1];
|
|
266
|
+
if (next === '"' || next === '\\') {
|
|
267
|
+
value += next;
|
|
268
|
+
end += 2;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
value += ch;
|
|
272
|
+
end += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (ch === '"') break;
|
|
276
|
+
value += ch;
|
|
277
|
+
end += 1;
|
|
278
|
+
}
|
|
279
|
+
const result = applyElementsChainAttr(key, value);
|
|
280
|
+
if (result.nthChild != null) nthChild = result.nthChild;
|
|
281
|
+
if (result.nthOfType != null) nthOfType = result.nthOfType;
|
|
282
|
+
if (result.text != null) text = result.text;
|
|
283
|
+
if (result.href != null) href = result.href;
|
|
284
|
+
if (result.attrKey != null) attrs[result.attrKey] = result.attrValue ?? '';
|
|
285
|
+
i = end + 1; // skip closing quote
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { tag, classes, attrs, text, nthChild, nthOfType, href };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Parse an `elements_chain` string into structured, leaf-first segments. */
|
|
292
|
+
export function parseElementsChain(chain: string): ElementsChainSegment[] {
|
|
293
|
+
// Split top-level by ';' respecting quoted strings.
|
|
294
|
+
const segments: string[] = [];
|
|
295
|
+
let current = '';
|
|
296
|
+
let inQuotes = false;
|
|
297
|
+
for (let i = 0; i < chain.length; i++) {
|
|
298
|
+
const ch = chain[i];
|
|
299
|
+
if (ch === '\\' && i + 1 < chain.length) {
|
|
300
|
+
current += ch + chain[i + 1];
|
|
301
|
+
i += 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (ch === '"') {
|
|
305
|
+
inQuotes = !inQuotes;
|
|
306
|
+
current += ch;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (ch === ';' && !inQuotes) {
|
|
310
|
+
segments.push(current);
|
|
311
|
+
current = '';
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
current += ch;
|
|
315
|
+
}
|
|
316
|
+
if (current.length > 0) {
|
|
317
|
+
segments.push(current);
|
|
318
|
+
}
|
|
319
|
+
return segments.map(parseElementsChainSegment).filter((segment): segment is ElementsChainSegment => segment != null);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
import.meta.vitest?.test("parseElementsChain parses a simple leaf+ancestor chain", ({ expect }) => {
|
|
323
|
+
const parsed = parseElementsChain('button.btn.btn-primary:nth-of-type="1":text="Save":attr__id="save-btn";div.container');
|
|
324
|
+
expect(parsed).toEqual([
|
|
325
|
+
{ tag: "button", classes: ["btn", "btn-primary"], attrs: { id: "save-btn" }, text: "Save", nthChild: null, nthOfType: 1, href: null },
|
|
326
|
+
{ tag: "div", classes: ["container"], attrs: {}, text: null, nthChild: null, nthOfType: null, href: null },
|
|
327
|
+
]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
import.meta.vitest?.test("parseElementsChain reverses class escaping for Tailwind-style tokens", ({ expect }) => {
|
|
331
|
+
// `md:hover:bg-blue-500` and `w-[1.5rem]` contain the prefix delimiters `:`/`.`,
|
|
332
|
+
// so the serializer escapes them; the parser must recover the literal classes.
|
|
333
|
+
const parsed = parseElementsChain('a.md\\:hover\\:bg-blue-500.w-\\[1\\.5rem\\]:href="/p?a=1"');
|
|
334
|
+
expect(parsed).toEqual([
|
|
335
|
+
{ tag: "a", classes: ["md:hover:bg-blue-500", "w-[1.5rem]"], attrs: { href: "/p?a=1" }, text: null, nthChild: null, nthOfType: null, href: "/p?a=1" },
|
|
336
|
+
]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
import.meta.vitest?.test("parseElementsChain unescapes quotes/backslashes and ignores ';' inside quoted values", ({ expect }) => {
|
|
340
|
+
const parsed = parseElementsChain('span:text="a \\"b\\"; c \\\\ d"');
|
|
341
|
+
expect(parsed).toEqual([
|
|
342
|
+
{ tag: "span", classes: [], attrs: {}, text: 'a "b"; c \\ d', nthChild: null, nthOfType: null, href: null },
|
|
343
|
+
]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
import.meta.vitest?.test("parseElementsChain drops empty/tagless segments", ({ expect }) => {
|
|
347
|
+
expect(parseElementsChain("")).toEqual([]);
|
|
348
|
+
expect(parseElementsChain(";;")).toEqual([]);
|
|
349
|
+
});
|