@drip-apex/sdk 0.1.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.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
@@ -0,0 +1,256 @@
1
+ import type { Mutation } from "../types";
2
+ import { upsertMutation } from "./artifacts";
3
+ import type {
4
+ ActiveEditorSession,
5
+ EditorArtifacts,
6
+ } from "./types";
7
+
8
+ const MAX_CONTEXT_CHARS = 180_000;
9
+ const MAX_CONVERSATION_MESSAGES = 12;
10
+ const CONTEXT_SELECTORS = [
11
+ "main",
12
+ "header",
13
+ "footer",
14
+ "section",
15
+ "article",
16
+ "nav",
17
+ "form",
18
+ "h1",
19
+ "h2",
20
+ "h3",
21
+ "p",
22
+ "button",
23
+ "a[href]",
24
+ "input",
25
+ "select",
26
+ "textarea",
27
+ "img",
28
+ "[role]",
29
+ "[data-testid]",
30
+ ].join(",");
31
+
32
+ export type EditorOperatorMessage = {
33
+ role: "user" | "assistant";
34
+ content: string;
35
+ };
36
+
37
+ export type EditorOperatorSelectedElement = {
38
+ selector: string;
39
+ tag?: string;
40
+ textPreview?: string;
41
+ alternatives?: string[];
42
+ };
43
+
44
+ export type EditorOperatorResult = {
45
+ artifacts: EditorArtifacts;
46
+ explanation: string;
47
+ artifactCount: number;
48
+ mergeStrategy: "append" | "replace";
49
+ };
50
+
51
+ function compactWhitespace(value: string): string {
52
+ return value.replace(/\s+/g, " ").trim();
53
+ }
54
+
55
+ export function redactEditorContextUrl(value: string, baseUrl?: string): string {
56
+ try {
57
+ const url = new URL(value, baseUrl);
58
+ if (url.protocol === "http:" || url.protocol === "https:") {
59
+ url.username = "";
60
+ url.password = "";
61
+ url.search = "";
62
+ url.hash = "";
63
+ return url.toString();
64
+ }
65
+ return `${url.protocol}[redacted]`;
66
+ } catch {
67
+ return value.split(/[?#]/, 1)[0].slice(0, 500);
68
+ }
69
+ }
70
+
71
+ function safeAttributeEntries(element: Element): Array<[string, string]> {
72
+ return Array.from(element.attributes)
73
+ .filter(({ name }) => {
74
+ const lower = name.toLowerCase();
75
+ return (
76
+ lower === "id" ||
77
+ lower === "class" ||
78
+ lower === "href" ||
79
+ lower === "src" ||
80
+ lower === "alt" ||
81
+ lower === "title" ||
82
+ lower === "name" ||
83
+ lower === "type" ||
84
+ lower === "role" ||
85
+ lower.startsWith("aria-") ||
86
+ lower === "data-testid" ||
87
+ lower === "data-section-id" ||
88
+ lower === "data-product-id"
89
+ );
90
+ })
91
+ .map(({ name, value }) => [
92
+ name,
93
+ name.toLowerCase() === "href" || name.toLowerCase() === "src"
94
+ ? redactEditorContextUrl(value, element.ownerDocument.baseURI)
95
+ : value.slice(0, 500),
96
+ ]);
97
+ }
98
+
99
+ function serializeElement(element: Element): string {
100
+ const tag = element.tagName.toLowerCase();
101
+ const attributes = safeAttributeEntries(element)
102
+ .map(([name, value]) => `${name}=${JSON.stringify(value)}`)
103
+ .join(" ");
104
+ const text =
105
+ element instanceof HTMLInputElement ||
106
+ element instanceof HTMLTextAreaElement ||
107
+ element instanceof HTMLSelectElement
108
+ ? ""
109
+ : compactWhitespace(element.textContent ?? "").slice(0, 600);
110
+ return `<${tag}${attributes ? ` ${attributes}` : ""}>${text}</${tag}>`;
111
+ }
112
+
113
+ export function collectEditorDomContext(
114
+ documentLike: Document = document,
115
+ ): string {
116
+ const lines = [
117
+ `url=${redactEditorContextUrl(documentLike.location?.href ?? "")}`,
118
+ `title=${compactWhitespace(documentLike.title ?? "")}`,
119
+ ];
120
+ const seen = new Set<Element>();
121
+ for (const element of Array.from(
122
+ documentLike.querySelectorAll(CONTEXT_SELECTORS),
123
+ )) {
124
+ if (seen.has(element) || element.id === "apex-editor-host") continue;
125
+ seen.add(element);
126
+ lines.push(serializeElement(element));
127
+ if (lines.join("\n").length >= MAX_CONTEXT_CHARS) break;
128
+ }
129
+ return lines.join("\n").slice(0, MAX_CONTEXT_CHARS);
130
+ }
131
+
132
+ function parseMutation(value: unknown): Mutation | null {
133
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
134
+ const mutation = value as Mutation;
135
+ return typeof mutation.selector === "string" &&
136
+ typeof mutation.action === "string"
137
+ ? mutation
138
+ : null;
139
+ }
140
+
141
+ function appendCode(current: string, generated: string): string {
142
+ if (!generated.trim()) return current;
143
+ if (!current.trim()) return generated;
144
+ return `${current.trimEnd()}\n\n${generated.trimStart()}`;
145
+ }
146
+
147
+ export function mergeEditorOperatorArtifacts(input: {
148
+ current: EditorArtifacts;
149
+ generatedMutations: unknown;
150
+ generatedCss: unknown;
151
+ generatedJs: unknown;
152
+ mergeStrategy: unknown;
153
+ }): EditorArtifacts {
154
+ const generated = Array.isArray(input.generatedMutations)
155
+ ? input.generatedMutations.map(parseMutation).filter((value): value is Mutation => Boolean(value))
156
+ : [];
157
+ const customCss =
158
+ typeof input.generatedCss === "string" ? input.generatedCss : "";
159
+ const customJs =
160
+ typeof input.generatedJs === "string" ? input.generatedJs : "";
161
+ if (input.mergeStrategy === "replace") {
162
+ return { mutations: generated, customCss, customJs };
163
+ }
164
+ return {
165
+ mutations: generated.reduce(
166
+ (artifacts, mutation) => upsertMutation(artifacts, mutation),
167
+ input.current,
168
+ ).mutations,
169
+ customCss: appendCode(input.current.customCss, customCss),
170
+ customJs: appendCode(input.current.customJs, customJs),
171
+ };
172
+ }
173
+
174
+ function errorMessage(payload: unknown, status: number): string {
175
+ if (payload && typeof payload === "object") {
176
+ const error = (payload as { error?: unknown }).error;
177
+ if (typeof error === "string" && error.trim()) return error.trim();
178
+ }
179
+ return `Operator request failed (${status})`;
180
+ }
181
+
182
+ export async function runEditorOperatorTurn(input: {
183
+ apiOrigin: string;
184
+ session: ActiveEditorSession;
185
+ variationId: string;
186
+ prompt: string;
187
+ artifacts: EditorArtifacts;
188
+ conversation: EditorOperatorMessage[];
189
+ pageContext?: string;
190
+ signal?: AbortSignal;
191
+ selectedElement?: EditorOperatorSelectedElement | null;
192
+ documentLike?: Document;
193
+ fetchImpl?: typeof fetch;
194
+ }): Promise<EditorOperatorResult> {
195
+ const fetchImpl = input.fetchImpl ?? fetch;
196
+ const response = await fetchImpl(
197
+ new URL(
198
+ `/api/editor-sessions/${encodeURIComponent(input.session.sid)}/operator`,
199
+ input.apiOrigin,
200
+ ),
201
+ {
202
+ method: "POST",
203
+ credentials: "omit",
204
+ cache: "no-store",
205
+ referrerPolicy: "no-referrer",
206
+ signal: input.signal,
207
+ headers: {
208
+ Authorization: `Bearer ${input.session.bearer}`,
209
+ "Content-Type": "application/json",
210
+ },
211
+ body: JSON.stringify({
212
+ prompt: input.prompt,
213
+ variationId: input.variationId,
214
+ domContext: collectEditorDomContext(input.documentLike),
215
+ pageContext:
216
+ input.pageContext ??
217
+ `Live merchant page: ${redactEditorContextUrl(input.session.targetUrl)}`,
218
+ existingMutations: input.artifacts.mutations,
219
+ existingCustomCss: input.artifacts.customCss,
220
+ existingCustomJs: input.artifacts.customJs,
221
+ conversation: input.conversation.slice(-MAX_CONVERSATION_MESSAGES),
222
+ selectedElement: input.selectedElement ?? undefined,
223
+ }),
224
+ },
225
+ );
226
+ const payload = (await response.json().catch(() => null)) as
227
+ | Record<string, unknown>
228
+ | null;
229
+ if (!response.ok || !payload) {
230
+ throw new Error(errorMessage(payload, response.status));
231
+ }
232
+ const mergeStrategy =
233
+ payload.mergeStrategy === "replace" ? "replace" : "append";
234
+ const artifacts = mergeEditorOperatorArtifacts({
235
+ current: input.artifacts,
236
+ generatedMutations: payload.mutations,
237
+ generatedCss: payload.customCss,
238
+ generatedJs: payload.customJs,
239
+ mergeStrategy,
240
+ });
241
+ const artifactCount =
242
+ (Array.isArray(payload.mutations) ? payload.mutations.length : 0) +
243
+ (typeof payload.customCss === "string" && payload.customCss.trim() ? 1 : 0) +
244
+ (typeof payload.customJs === "string" && payload.customJs.trim() ? 1 : 0);
245
+ return {
246
+ artifacts,
247
+ explanation:
248
+ typeof payload.explanation === "string" && payload.explanation.trim()
249
+ ? payload.explanation.trim()
250
+ : artifactCount
251
+ ? "Draft treatment created. Review the result before QA."
252
+ : "No draft changes were produced.",
253
+ artifactCount,
254
+ mergeStrategy,
255
+ };
256
+ }
@@ -0,0 +1,58 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { ElementPicker } from "./picker";
4
+
5
+ function createPicker(onSelect = vi.fn()) {
6
+ const box = document.createElement("div");
7
+ const label = document.createElement("div");
8
+ document.body.append(box, label);
9
+ const picker = new ElementPicker(
10
+ { box, label },
11
+ onSelect,
12
+ vi.fn(),
13
+ );
14
+ return { picker, onSelect };
15
+ }
16
+
17
+ afterEach(() => {
18
+ document.body.innerHTML = "";
19
+ });
20
+
21
+ describe("ElementPicker", () => {
22
+ it("selects elements reachable by document selectors", () => {
23
+ const button = document.createElement("button");
24
+ button.id = "buy";
25
+ document.body.appendChild(button);
26
+ const { picker, onSelect } = createPicker();
27
+ picker.setActive(true);
28
+
29
+ button.dispatchEvent(new MouseEvent("click", {
30
+ bubbles: true,
31
+ cancelable: true,
32
+ composed: true,
33
+ }));
34
+
35
+ expect(onSelect).toHaveBeenCalledWith(button, "#buy");
36
+ picker.destroy();
37
+ });
38
+
39
+ it("rejects elements inside a merchant shadow root", () => {
40
+ const host = document.createElement("div");
41
+ document.body.appendChild(host);
42
+ const shadow = host.attachShadow({ mode: "open" });
43
+ const button = document.createElement("button");
44
+ button.id = "shadow-buy";
45
+ shadow.appendChild(button);
46
+ const { picker, onSelect } = createPicker();
47
+ picker.setActive(true);
48
+
49
+ button.dispatchEvent(new MouseEvent("click", {
50
+ bubbles: true,
51
+ cancelable: true,
52
+ composed: true,
53
+ }));
54
+
55
+ expect(onSelect).not.toHaveBeenCalled();
56
+ picker.destroy();
57
+ });
58
+ });
@@ -0,0 +1,152 @@
1
+ import { elementLabel, generateDeterministicSelector } from "./selector";
2
+
3
+ type PickerOverlay = {
4
+ box: HTMLElement;
5
+ label: HTMLElement;
6
+ };
7
+
8
+ function editorElement(element: Element | null): boolean {
9
+ return Boolean(element?.closest("#apex-editor-host"));
10
+ }
11
+
12
+ function editorEvent(event: Event): boolean {
13
+ return event.composedPath().some(
14
+ (item) => item instanceof Element && item.id === "apex-editor-host",
15
+ );
16
+ }
17
+
18
+ function selectableElement(element: Element): boolean {
19
+ return element.ownerDocument === document && element.getRootNode() === document;
20
+ }
21
+
22
+ export class ElementPicker {
23
+ private active = false;
24
+ private hovered: Element | null = null;
25
+ private selected: Element | null = null;
26
+
27
+ constructor(
28
+ private readonly overlay: PickerOverlay,
29
+ private readonly onSelect: (element: Element, selector: string) => void,
30
+ private readonly onActiveChange: (active: boolean) => void,
31
+ private readonly onUnsupported?: () => void,
32
+ ) {
33
+ document.addEventListener("pointermove", this.onPointerMove, true);
34
+ document.addEventListener("click", this.onClick, true);
35
+ document.addEventListener("keydown", this.onKeyDown, true);
36
+ window.addEventListener("scroll", this.refresh, true);
37
+ window.addEventListener("resize", this.refresh);
38
+ }
39
+
40
+ setActive(active: boolean): void {
41
+ this.active = active;
42
+ if (!active) this.hovered = null;
43
+ this.renderOverlay(active ? this.hovered : this.selected);
44
+ this.onActiveChange(active);
45
+ }
46
+
47
+ setSelected(element: Element | null): void {
48
+ this.selected = element;
49
+ this.renderOverlay(this.active ? this.hovered : element);
50
+ }
51
+
52
+ destroy(): void {
53
+ document.removeEventListener("pointermove", this.onPointerMove, true);
54
+ document.removeEventListener("click", this.onClick, true);
55
+ document.removeEventListener("keydown", this.onKeyDown, true);
56
+ window.removeEventListener("scroll", this.refresh, true);
57
+ window.removeEventListener("resize", this.refresh);
58
+ }
59
+
60
+ private onPointerMove = (event: PointerEvent) => {
61
+ if (!this.active) return;
62
+ if (editorEvent(event)) return;
63
+ const target = event.composedPath()[0];
64
+ const element = target instanceof Element ? target : null;
65
+ if (!element || !selectableElement(element) || editorElement(element)) return;
66
+ this.hovered = element;
67
+ this.renderOverlay(element);
68
+ };
69
+
70
+ private onClick = (event: MouseEvent) => {
71
+ if (!this.active) return;
72
+ if (editorEvent(event)) return;
73
+ const target = event.composedPath()[0];
74
+ const element = target instanceof Element ? target : null;
75
+ if (!element) return;
76
+ if (!selectableElement(element) || editorElement(element)) {
77
+ if (!editorElement(element)) {
78
+ event.preventDefault();
79
+ event.stopImmediatePropagation();
80
+ this.onUnsupported?.();
81
+ }
82
+ return;
83
+ }
84
+ event.preventDefault();
85
+ event.stopImmediatePropagation();
86
+ this.selected = element;
87
+ this.hovered = null;
88
+ this.active = false;
89
+ this.renderOverlay(element);
90
+ this.onSelect(element, generateDeterministicSelector(element));
91
+ this.onActiveChange(false);
92
+ };
93
+
94
+ private onKeyDown = (event: KeyboardEvent) => {
95
+ if (event.key === "Escape" && this.active) {
96
+ event.preventDefault();
97
+ this.setActive(false);
98
+ return;
99
+ }
100
+ const target = event.target;
101
+ if (
102
+ target instanceof HTMLInputElement ||
103
+ target instanceof HTMLTextAreaElement ||
104
+ target instanceof HTMLSelectElement ||
105
+ (target instanceof HTMLElement && target.isContentEditable)
106
+ ) {
107
+ return;
108
+ }
109
+ if (!this.selected || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) {
110
+ return;
111
+ }
112
+ const next = event.key === "ArrowUp"
113
+ ? this.selected.parentElement
114
+ : event.key === "ArrowDown"
115
+ ? this.selected.firstElementChild
116
+ : event.key === "ArrowLeft"
117
+ ? this.selected.previousElementSibling
118
+ : this.selected.nextElementSibling;
119
+ if (!next || !selectableElement(next) || editorElement(next)) return;
120
+ event.preventDefault();
121
+ this.selected = next;
122
+ this.renderOverlay(next);
123
+ this.onSelect(next, generateDeterministicSelector(next));
124
+ };
125
+
126
+ private refresh = () => {
127
+ this.renderOverlay(this.active ? this.hovered : this.selected);
128
+ };
129
+
130
+ private renderOverlay(element: Element | null): void {
131
+ if (!element?.isConnected) {
132
+ this.overlay.box.hidden = true;
133
+ this.overlay.label.hidden = true;
134
+ return;
135
+ }
136
+ const rect = element.getBoundingClientRect();
137
+ if (rect.width <= 0 || rect.height <= 0) {
138
+ this.overlay.box.hidden = true;
139
+ this.overlay.label.hidden = true;
140
+ return;
141
+ }
142
+ this.overlay.box.hidden = false;
143
+ this.overlay.label.hidden = false;
144
+ this.overlay.box.dataset.state = this.active && element !== this.selected ? "hover" : "selected";
145
+ this.overlay.box.style.transform = `translate(${Math.round(rect.left)}px,${Math.round(rect.top)}px)`;
146
+ this.overlay.box.style.width = `${Math.round(rect.width)}px`;
147
+ this.overlay.box.style.height = `${Math.round(rect.height)}px`;
148
+ this.overlay.label.textContent = elementLabel(element);
149
+ const labelTop = rect.top >= 28 ? rect.top - 24 : rect.bottom + 4;
150
+ this.overlay.label.style.transform = `translate(${Math.max(4, Math.round(rect.left))}px,${Math.round(labelTop)}px)`;
151
+ }
152
+ }
@@ -0,0 +1,44 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { generateDeterministicSelector } from "./selector";
4
+
5
+ describe("generateDeterministicSelector", () => {
6
+ beforeEach(() => {
7
+ document.body.innerHTML = "";
8
+ });
9
+
10
+ it("prefers a stable unique id", () => {
11
+ document.body.innerHTML = `<main><button id="add-to-cart">Add</button></main>`;
12
+ const element = document.querySelector("button")!;
13
+ expect(generateDeterministicSelector(element)).toBe("#add-to-cart");
14
+ });
15
+
16
+ it("rejects volatile ids and prefers stable data attributes", () => {
17
+ document.body.innerHTML = `<main><button id="react-928374" data-testid="buy-button">Buy</button></main>`;
18
+ const element = document.querySelector("button")!;
19
+ expect(generateDeterministicSelector(element)).toBe('button[data-testid="buy-button"]');
20
+ });
21
+
22
+ it("rejects duplicate ids and returns a selector unique to the chosen element", () => {
23
+ document.body.innerHTML = `
24
+ <main>
25
+ <section><button id="duplicate">One</button></section>
26
+ <section><button id="duplicate">Two</button></section>
27
+ </main>`;
28
+ const element = document.querySelectorAll("button")[1];
29
+
30
+ const selector = generateDeterministicSelector(element);
31
+
32
+ expect(selector).not.toBe("#duplicate");
33
+ expect(document.querySelectorAll(selector)).toHaveLength(1);
34
+ expect(document.querySelector(selector)).toBe(element);
35
+ });
36
+
37
+ it("builds a deterministic ancestor path when direct selectors are broad", () => {
38
+ document.body.innerHTML = `<main><section class="product"><button>One</button></section><section class="product"><button>Two</button></section></main>`;
39
+ const element = document.querySelectorAll("button")[1];
40
+ const selector = generateDeterministicSelector(element);
41
+ expect(document.querySelectorAll(selector)).toHaveLength(1);
42
+ expect(document.querySelector(selector)).toBe(element);
43
+ });
44
+ });
@@ -0,0 +1,110 @@
1
+ const UNSTABLE_ID = /(?:^|[-_])(react|vue|ember|svelte|uid|uuid|random|generated)[-_]?|\d{5,}/i;
2
+ const UNSTABLE_CLASS = /^(active|current|selected|hover|focus|open|closed|js-|is-|has-)|\d{4,}/i;
3
+
4
+ function escapeCss(value: string): string {
5
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
6
+ return CSS.escape(value);
7
+ }
8
+ return value.replace(/[^a-zA-Z0-9_-]/g, (character) => `\\${character}`);
9
+ }
10
+
11
+ function quotedAttribute(name: string, value: string): string {
12
+ return `[${name}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
13
+ }
14
+
15
+ function isUnique(selector: string, root: ParentNode): boolean {
16
+ try {
17
+ return root.querySelectorAll(selector).length === 1;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ function stableId(element: Element): string | null {
24
+ const id = element.id.trim();
25
+ return id && id.length <= 80 && !UNSTABLE_ID.test(id) ? id : null;
26
+ }
27
+
28
+ function stableAttribute(element: Element): { name: string; value: string } | null {
29
+ const names = ["data-testid", "data-test", "data-cy", "data-drip-id", "name", "aria-label"];
30
+ for (const name of names) {
31
+ const value = element.getAttribute(name)?.trim();
32
+ if (value && value.length <= 120) return { name, value };
33
+ }
34
+ for (const attribute of Array.from(element.attributes)) {
35
+ if (
36
+ attribute.name.startsWith("data-") &&
37
+ !attribute.name.startsWith("data-react") &&
38
+ attribute.value &&
39
+ attribute.value.length <= 120 &&
40
+ !UNSTABLE_ID.test(attribute.value)
41
+ ) {
42
+ return { name: attribute.name, value: attribute.value };
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function stableClasses(element: Element): string[] {
49
+ return Array.from(element.classList)
50
+ .filter((name) => name.length <= 64 && !UNSTABLE_CLASS.test(name))
51
+ .slice(0, 2);
52
+ }
53
+
54
+ function segment(element: Element, root: ParentNode): string {
55
+ const tag = element.tagName.toLowerCase();
56
+ const id = stableId(element);
57
+ if (id) {
58
+ const candidate = `#${escapeCss(id)}`;
59
+ if (isUnique(candidate, root)) return candidate;
60
+ }
61
+ const attribute = stableAttribute(element);
62
+ if (attribute) {
63
+ const candidate = `${tag}${quotedAttribute(attribute.name, attribute.value)}`;
64
+ if (isUnique(candidate, root)) return candidate;
65
+ }
66
+ const classes = stableClasses(element);
67
+ if (classes.length) {
68
+ const candidate = `${tag}.${classes.map(escapeCss).join(".")}`;
69
+ if (isUnique(candidate, root)) return candidate;
70
+ }
71
+ const parent = element.parentElement;
72
+ if (!parent) return tag;
73
+ const sameTag = Array.from(parent.children).filter((child) => child.tagName === element.tagName);
74
+ return sameTag.length > 1
75
+ ? `${tag}:nth-of-type(${sameTag.indexOf(element) + 1})`
76
+ : tag;
77
+ }
78
+
79
+ export function generateDeterministicSelector(
80
+ element: Element,
81
+ root: ParentNode = document,
82
+ ): string {
83
+ const ownerDocument = element.ownerDocument ?? document;
84
+ const id = stableId(element);
85
+ if (id) {
86
+ const candidate = `#${escapeCss(id)}`;
87
+ if (isUnique(candidate, root)) return candidate;
88
+ }
89
+ const direct = segment(element, root);
90
+ if (isUnique(direct, root)) return direct;
91
+
92
+ const parts = [direct];
93
+ let current = element.parentElement;
94
+ while (current && current !== ownerDocument.documentElement && parts.length < 5) {
95
+ const part = segment(current, root);
96
+ parts.unshift(part);
97
+ const candidate = parts.join(" > ");
98
+ if (isUnique(candidate, root)) return candidate;
99
+ if (part.startsWith("#")) break;
100
+ current = current.parentElement;
101
+ }
102
+ return parts.join(" > ");
103
+ }
104
+
105
+ export function elementLabel(element: Element): string {
106
+ const tag = element.tagName.toLowerCase();
107
+ const className = stableClasses(element)[0];
108
+ const rect = element.getBoundingClientRect();
109
+ return `${tag}${className ? `.${className}` : ""} ${Math.round(rect.width)}x${Math.round(rect.height)}`;
110
+ }