@antelopejs/dms-marketing 0.6.1 → 0.7.1

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 (202) hide show
  1. package/README.md +2 -2
  2. package/dist/config.d.ts +3 -0
  3. package/dist/config.js +11 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/data-api/websites.d.ts +10 -4
  6. package/dist/data-api/websites.js +79 -3
  7. package/dist/data-api/websites.js.map +1 -1
  8. package/dist/db/models/funnels.model.d.ts +0 -1
  9. package/dist/db/models/funnels.model.js +0 -18
  10. package/dist/db/models/funnels.model.js.map +1 -1
  11. package/dist/db/models/page_snapshots.model.d.ts +2 -1
  12. package/dist/db/models/page_snapshots.model.js +6 -14
  13. package/dist/db/models/page_snapshots.model.js.map +1 -1
  14. package/dist/db/models/websites.model.d.ts +1 -1
  15. package/dist/db/models/websites.model.js +9 -29
  16. package/dist/db/models/websites.model.js.map +1 -1
  17. package/dist/db/tables/funnels.table.d.ts +0 -1
  18. package/dist/db/tables/funnels.table.js +0 -4
  19. package/dist/db/tables/funnels.table.js.map +1 -1
  20. package/dist/db/tables/page_snapshots.table.d.ts +1 -1
  21. package/dist/db/tables/page_snapshots.table.js +4 -4
  22. package/dist/db/tables/page_snapshots.table.js.map +1 -1
  23. package/dist/db/tables/websites.table.d.ts +2 -1
  24. package/dist/db/tables/websites.table.js +4 -4
  25. package/dist/db/tables/websites.table.js.map +1 -1
  26. package/dist/frontend-module.d.ts +6 -0
  27. package/dist/frontend-module.js +46 -0
  28. package/dist/frontend-module.js.map +1 -0
  29. package/dist/implementations/dms-marketing/index.js +0 -5
  30. package/dist/implementations/dms-marketing/index.js.map +1 -1
  31. package/dist/index.js +3 -13
  32. package/dist/index.js.map +1 -1
  33. package/dist/pages/acquisition/page.d.ts +1 -0
  34. package/dist/pages/acquisition/page.js +9 -4
  35. package/dist/pages/acquisition/page.js.map +1 -1
  36. package/dist/pages/blocks.d.ts +11 -2
  37. package/dist/pages/blocks.js +43 -5
  38. package/dist/pages/blocks.js.map +1 -1
  39. package/dist/pages/funnel/page.d.ts +1 -0
  40. package/dist/pages/funnel/page.js +1 -0
  41. package/dist/pages/funnel/page.js.map +1 -1
  42. package/dist/pages/funnels/page.d.ts +1 -0
  43. package/dist/pages/funnels/page.js +4 -2
  44. package/dist/pages/funnels/page.js.map +1 -1
  45. package/dist/pages/overview/page.d.ts +1 -0
  46. package/dist/pages/overview/page.js +5 -6
  47. package/dist/pages/overview/page.js.map +1 -1
  48. package/dist/pages/pages/page.d.ts +1 -0
  49. package/dist/pages/pages/page.js +1 -0
  50. package/dist/pages/pages/page.js.map +1 -1
  51. package/dist/pages/settings/form.js +1 -0
  52. package/dist/pages/settings/form.js.map +1 -1
  53. package/dist/pages/websites/page.d.ts +2 -3
  54. package/dist/pages/websites/page.js +63 -5
  55. package/dist/pages/websites/page.js.map +1 -1
  56. package/dist/routes/dashboard-tracking.js +5 -1
  57. package/dist/routes/dashboard-tracking.js.map +1 -1
  58. package/dist/routes/funnels.d.ts +2 -0
  59. package/dist/routes/funnels.js +6 -5
  60. package/dist/routes/funnels.js.map +1 -1
  61. package/dist/routes/public-endpoints.js +1 -1
  62. package/dist/routes/public-endpoints.js.map +1 -1
  63. package/dist/routes/snapshot.d.ts +1 -1
  64. package/dist/routes/snapshot.js +30 -13
  65. package/dist/routes/snapshot.js.map +1 -1
  66. package/dist/routes/stats.d.ts +10 -4
  67. package/dist/routes/stats.js +77 -23
  68. package/dist/routes/stats.js.map +1 -1
  69. package/dist/routes/websites.d.ts +5 -1
  70. package/dist/routes/websites.js +31 -13
  71. package/dist/routes/websites.js.map +1 -1
  72. package/dist/services/acquisition-report.d.ts +1 -0
  73. package/dist/services/acquisition-report.js +1 -0
  74. package/dist/services/acquisition-report.js.map +1 -1
  75. package/dist/services/collect.js +7 -2
  76. package/dist/services/collect.js.map +1 -1
  77. package/dist/services/dashboard-tracking.d.ts +7 -14
  78. package/dist/services/dashboard-tracking.js +42 -26
  79. package/dist/services/dashboard-tracking.js.map +1 -1
  80. package/dist/services/declarations.d.ts +0 -2
  81. package/dist/services/declarations.js +0 -11
  82. package/dist/services/declarations.js.map +1 -1
  83. package/dist/services/declared-funnel.js +11 -99
  84. package/dist/services/declared-funnel.js.map +1 -1
  85. package/dist/services/declared-snapshot.js +8 -37
  86. package/dist/services/declared-snapshot.js.map +1 -1
  87. package/dist/services/declared-website.js +5 -46
  88. package/dist/services/declared-website.js.map +1 -1
  89. package/dist/services/funnels-table.d.ts +3 -2
  90. package/dist/services/funnels-table.js +9 -9
  91. package/dist/services/funnels-table.js.map +1 -1
  92. package/dist/services/heatmap.d.ts +6 -0
  93. package/dist/services/heatmap.js +82 -17
  94. package/dist/services/heatmap.js.map +1 -1
  95. package/dist/services/index.d.ts +1 -0
  96. package/dist/services/index.js +1 -0
  97. package/dist/services/index.js.map +1 -1
  98. package/dist/services/prune.js +6 -1
  99. package/dist/services/prune.js.map +1 -1
  100. package/dist/services/realtime.d.ts +3 -0
  101. package/dist/services/realtime.js +49 -0
  102. package/dist/services/realtime.js.map +1 -0
  103. package/dist/services/request-body.d.ts +1 -0
  104. package/dist/services/request-body.js +18 -0
  105. package/dist/services/request-body.js.map +1 -1
  106. package/dist/services/sessionize.d.ts +1 -0
  107. package/dist/services/sessionize.js +9 -0
  108. package/dist/services/sessionize.js.map +1 -1
  109. package/dist/services/snapshots.d.ts +29 -1
  110. package/dist/services/snapshots.js +151 -26
  111. package/dist/services/snapshots.js.map +1 -1
  112. package/dist/services/tenant-website.d.ts +11 -0
  113. package/dist/services/tenant-website.js +27 -5
  114. package/dist/services/tenant-website.js.map +1 -1
  115. package/dist/services/website-report.d.ts +5 -0
  116. package/dist/services/website-report.js +13 -1
  117. package/dist/services/website-report.js.map +1 -1
  118. package/dist/services/workspace-scope.d.ts +31 -0
  119. package/dist/services/workspace-scope.js +72 -0
  120. package/dist/services/workspace-scope.js.map +1 -0
  121. package/dist/types/constants.d.ts +7 -0
  122. package/dist/types/constants.js +8 -1
  123. package/dist/types/constants.js.map +1 -1
  124. package/dist/types/events.d.ts +25 -25
  125. package/dist/types/snapshots.d.ts +29 -29
  126. package/dist/types/snapshots.js +1 -1
  127. package/dist/types/snapshots.js.map +1 -1
  128. package/dist/utils/funnel-steps-type.d.ts +13 -13
  129. package/frontend-vue/app/components/ChannelsCard.vue +19 -2
  130. package/frontend-vue/app/components/Context.vue +50 -376
  131. package/frontend-vue/app/components/FunnelBuilder.vue +28 -22
  132. package/frontend-vue/app/components/FunnelFigure.vue +15 -2
  133. package/frontend-vue/app/components/FunnelReport.vue +3 -0
  134. package/frontend-vue/app/components/FunnelTemplates.vue +15 -13
  135. package/frontend-vue/app/components/InstallGuide.vue +1 -1
  136. package/frontend-vue/app/components/PagePreview.vue +330 -74
  137. package/frontend-vue/app/components/PagesExplorer.vue +230 -85
  138. package/frontend-vue/app/components/TopListTabs.vue +51 -18
  139. package/frontend-vue/app/components/WebsiteCard.vue +394 -0
  140. package/frontend-vue/app/components/WebsiteCreate.vue +159 -0
  141. package/frontend-vue/app/components/WebsitePicker.vue +210 -0
  142. package/frontend-vue/app/components/WebsiteSettings.vue +539 -0
  143. package/frontend-vue/app/components/WorkspaceScope.vue +119 -0
  144. package/frontend-vue/app/composables/useContextWebsite.ts +105 -0
  145. package/frontend-vue/app/composables/useCopyDraft.ts +47 -0
  146. package/frontend-vue/app/composables/useMarketingApi.ts +92 -4
  147. package/frontend-vue/app/composables/useMarketingReads.ts +32 -23
  148. package/frontend-vue/app/composables/useMarketingRoutes.ts +25 -2
  149. package/frontend-vue/app/composables/useRecordCrumb.ts +17 -0
  150. package/frontend-vue/app/composables/useSnapshotFrame.ts +35 -1
  151. package/frontend-vue/app/utils/anchor-drift.ts +58 -0
  152. package/frontend-vue/app/utils/drift-dismissals.ts +20 -0
  153. package/frontend-vue/app/utils/fill-height.ts +141 -0
  154. package/frontend-vue/app/utils/format.ts +21 -2
  155. package/frontend-vue/app/utils/funnel-draft.ts +22 -0
  156. package/frontend-vue/app/utils/heatmap.ts +17 -0
  157. package/frontend-vue/app/utils/page-snapshot.ts +75 -0
  158. package/frontend-vue/app/utils/snapshot-options.ts +148 -0
  159. package/frontend-vue/app/utils/snapshotScrollRoot.ts +93 -0
  160. package/frontend-vue/app/utils/website-card.ts +93 -0
  161. package/frontend-vue/app/utils/website-purge.ts +95 -0
  162. package/frontend-vue/app/utils/website-settings.ts +121 -0
  163. package/frontend-vue/app/utils/website-state.ts +93 -0
  164. package/frontend-vue/app/utils/workspace-scope.ts +51 -0
  165. package/frontend-vue/i18n/locales/marketing-en-GB.json +112 -36
  166. package/frontend-vue/i18n/locales/marketing-fr-FR.json +112 -36
  167. package/frontend-vue/package.json +1 -1
  168. package/frontend-vue/tests/anchor-drift.test.ts +59 -0
  169. package/frontend-vue/tests/build-flags.test.ts +38 -0
  170. package/frontend-vue/tests/context-bar.test.ts +226 -0
  171. package/frontend-vue/tests/copy-draft.test.ts +64 -0
  172. package/frontend-vue/tests/drift-dismissals.test.ts +12 -0
  173. package/frontend-vue/tests/fill-height.test.ts +110 -0
  174. package/frontend-vue/tests/format.test.ts +14 -0
  175. package/frontend-vue/tests/funnel-draft.test.ts +37 -0
  176. package/frontend-vue/tests/heatmap.test.ts +25 -0
  177. package/frontend-vue/tests/install-guide.test.ts +25 -0
  178. package/frontend-vue/tests/marketing-reads.test.ts +20 -6
  179. package/frontend-vue/tests/page-snapshot.test.ts +62 -0
  180. package/frontend-vue/tests/record-crumb.test.ts +43 -0
  181. package/frontend-vue/tests/registration.test.ts +4 -1
  182. package/frontend-vue/tests/routes.test.ts +21 -0
  183. package/frontend-vue/tests/save-funnel.test.ts +47 -0
  184. package/frontend-vue/tests/server-parity.test.ts +3 -0
  185. package/frontend-vue/tests/snapshot-options.test.ts +148 -0
  186. package/frontend-vue/tests/snapshot-scroll-root.test.ts +84 -0
  187. package/frontend-vue/tests/stubs/frontend-module.ts +5 -1
  188. package/frontend-vue/tests/top-list-tabs.test.ts +158 -0
  189. package/frontend-vue/tests/website-card.test.ts +119 -0
  190. package/frontend-vue/tests/website-purge.test.ts +70 -0
  191. package/frontend-vue/tests/website-settings.test.ts +133 -0
  192. package/frontend-vue/tests/website-state.test.ts +96 -0
  193. package/frontend-vue/tests/workspace-scope-control.test.ts +113 -0
  194. package/frontend-vue/tests/workspace-scope.test.ts +55 -0
  195. package/package.json +8 -7
  196. package/skills/dms-marketing/SKILL.md +54 -12
  197. package/static/snapshot.js +539 -48
  198. package/static/tracker.js +154 -5
  199. package/dist/services/readiness.d.ts +0 -2
  200. package/dist/services/readiness.js +0 -15
  201. package/dist/services/readiness.js.map +0 -1
  202. package/frontend-vue/app/components/WebsitesGrid.vue +0 -812
@@ -17,12 +17,23 @@
17
17
  * an element with an empty box of its size. `maskText` masks every text
18
18
  * node and text attribute of the page; data-* and link paths stay, since
19
19
  * styles select on them.
20
+ *
21
+ * Transient overlays open at capture time — a prompt, a toast, an open
22
+ * menu or popover floating over the page — are left out (see
23
+ * isTransientOverlay): they belong to that one visit, not to the page.
24
+ *
25
+ * A page whose document does not scroll, an inner container doing it
26
+ * instead (see the scroll root in static/tracker.js), is captured with that
27
+ * container marked `data-marketing-scroll-root` and the height of its whole
28
+ * content recorded: the preview unrolls it, as tall as the page really is.
20
29
  */
21
30
  (() => {
22
31
  const CONFIG_GLOBAL = "__dmsMarketingSnapshotConfig";
23
32
  const CONTEXT_GLOBAL = "__dmsMarketingSnapshotContext";
24
33
  const MASK_MARKER = "dms-marketing-mask";
25
34
  const BLOCK_MARKER = "dms-marketing-block";
35
+ /** Marks the page's inner scroll container in the capture (see above). */
36
+ const SCROLL_ROOT_ATTRIBUTE = "data-marketing-scroll-root";
26
37
  const MASK_CHAR = "x";
27
38
  /** Mirrors the backend MAX_URL_LENGTH. */
28
39
  const MAX_URL_LENGTH = 500;
@@ -84,6 +95,21 @@
84
95
  "background",
85
96
  ]);
86
97
  const LINK_TAGS = new Set(["a", "area"]);
98
+ /** Roles of the overlays a visit opens over the page, dropped when they
99
+ * float over it (see isTransientOverlay). */
100
+ const OVERLAY_ROLES = new Set([
101
+ "dialog",
102
+ "alertdialog",
103
+ "tooltip",
104
+ "menu",
105
+ "listbox",
106
+ ]);
107
+ const LIVE_ROLES = new Set(["status", "alert", "log"]);
108
+ const LIVE_POLITENESS = new Set(["polite", "assertive"]);
109
+ /** Lists a toast viewport stacks its toasts in. */
110
+ const LIST_TAGS = new Set(["ol", "ul"]);
111
+ /** In the top layer: a modal `<dialog>`, an open popover. */
112
+ const TOP_LAYER_SELECTOR = ":modal, :popover-open";
87
113
  /** Attributes that carry text a visitor could read, masked with it. */
88
114
  const TEXT_ATTRIBUTES = new Set([
89
115
  "title",
@@ -106,18 +132,21 @@
106
132
  return value && typeof value === "object" ? value : null;
107
133
  }
108
134
 
109
- const config = readGlobal(CONFIG_GLOBAL);
135
+ /** Loaded by the module's tests (CommonJS) rather than by a page: the
136
+ * pure parts are exported instead of capturing anything. */
137
+ const isModule =
138
+ typeof window === "undefined" &&
139
+ typeof module === "object" &&
140
+ module !== null;
141
+ const config = isModule ? null : readGlobal(CONFIG_GLOBAL);
110
142
  const maskAllText = config?.maskText === true;
111
- const context = readGlobal(CONTEXT_GLOBAL);
112
- if (
113
- !config ||
114
- !context ||
115
- typeof config.token !== "string" ||
116
- typeof context.endpoint !== "string" ||
117
- window.top !== window
118
- ) {
119
- return;
120
- }
143
+ const context = isModule ? null : readGlobal(CONTEXT_GLOBAL);
144
+ const asked =
145
+ !!config &&
146
+ !!context &&
147
+ typeof config.token === "string" &&
148
+ typeof context.endpoint === "string" &&
149
+ window.top === window;
121
150
 
122
151
  // --- Text -----------------------------------------------------------------
123
152
 
@@ -203,34 +232,306 @@
203
232
  }
204
233
 
205
234
  // --- Stylesheets ----------------------------------------------------------
235
+ //
236
+ // A page carries far more CSS than it uses — a framework's utilities, a
237
+ // component library, every layer a host app loads — and a capture inlining
238
+ // all of it routinely weighs megabytes. A style rule is kept only when its
239
+ // selector matches an element of the captured document, tested with the
240
+ // states it depends on (hover, focus, open…) and its pseudo-element taken
241
+ // off, so whatever could show in the snapshot keeps its style; a selector
242
+ // the browser cannot test is kept. Custom properties that no kept rule,
243
+ // inline style or kept property value refers to are dropped. Conditional
244
+ // wrappers (@media, @supports, @container…) stay around the rules they
245
+ // still hold — every layout's, not only the visitor's — and @layer blocks
246
+ // stay even empty (their order is the cascade's), as do @font-face,
247
+ // @keyframes, @property and the other at-rules. A stylesheet identical to
248
+ // one already serialized is not repeated.
249
+
250
+ const STYLE_RULE = 1;
251
+ const IMPORT_RULE = 3;
252
+ const KEYFRAMES_RULE = 7;
253
+ /** Selectors `querySelector` cannot answer for from the sheet's scope:
254
+ * the rule is kept. */
255
+ const UNTESTABLE_SELECTOR_PATTERN =
256
+ /:host|::slotted|::part|::cue|::highlight|::view-transition|:state\(|&|:scope/;
257
+ /** A pseudo-element renders on its element: the rule is tested on it. */
258
+ const PSEUDO_ELEMENT_PATTERN =
259
+ /::[\w-]+|:(?:before|after|first-line|first-letter)(?![\w-])/g;
260
+ /** States a snapshot may be looked at in, or a visitor's input sets: the
261
+ * rule is tested as if the state held. */
262
+ const STATE_PSEUDO_CLASS_PATTERN =
263
+ /:(?:hover|focus|focus-visible|focus-within|active|visited|link|any-link|target|target-within|checked|indeterminate|placeholder-shown|autofill|valid|invalid|user-valid|user-invalid|in-range|out-of-range|open|closed|popover-open|modal|fullscreen|playing|paused)(?![\w-])/g;
264
+ const COMBINATOR_SPACING_PATTERN = /\s*([>+~])\s*/g;
265
+ /** A compound the stripping above left empty stands for any element. */
266
+ const EMPTY_COMPOUND_PATTERN = /(^|[\s>+~(,])(?=$|[\s>+~),])/g;
267
+ const NEGATED_ANY_PATTERN = /:not\(\*\)/g;
268
+ const ESCAPED_COLON_PATTERN = /\\:/g;
269
+ /** Stands in for an escaped colon while pseudo-classes are stripped. */
270
+ const ESCAPED_COLON_TOKEN = "\u0001";
271
+ const CSS_VARIABLE_PATTERN = /var\(\s*(--[\w-]+)/g;
272
+ const LAYER_PRELUDE_PATTERN = /^@layer\b/;
273
+
274
+ /** Selector list split on its top-level commas. */
275
+ function splitSelectorList(list) {
276
+ const parts = [];
277
+ let depth = 0;
278
+ let quote = "";
279
+ let start = 0;
280
+ for (let index = 0; index < list.length; index++) {
281
+ const char = list[index];
282
+ if (quote) {
283
+ if (char === "\\") {
284
+ index++;
285
+ } else if (char === quote) {
286
+ quote = "";
287
+ }
288
+ } else if (char === '"' || char === "'") {
289
+ quote = char;
290
+ } else if (char === "(" || char === "[") {
291
+ depth++;
292
+ } else if (char === ")" || char === "]") {
293
+ depth--;
294
+ } else if (char === "," && depth === 0) {
295
+ parts.push(list.slice(start, index).trim());
296
+ start = index + 1;
297
+ }
298
+ }
299
+ parts.push(list.slice(start).trim());
300
+ return parts;
301
+ }
206
302
 
207
- /**
208
- * Rules read from the CSSOM, not from the element's text: CSS-in-JS
209
- * libraries insert theirs with `insertRule`, leaving the `<style>` empty.
210
- * Null when the browser refuses (cross-origin sheet); the caller keeps a
211
- * link then. Readable imports are inlined in place, under their media.
212
- */
213
- function rulesText(sheet, base) {
214
- let rules;
303
+ /** One selector as tested, null when it cannot be (the rule stays). */
304
+ function testedSelector(selector) {
305
+ if (UNTESTABLE_SELECTOR_PATTERN.test(selector)) {
306
+ return null;
307
+ }
308
+ // An escaped colon belongs to a class name (`.md\:hover\:flex`).
309
+ const escaped = selector.replace(ESCAPED_COLON_PATTERN, ESCAPED_COLON_TOKEN);
310
+ const stripped = escaped
311
+ .replace(PSEUDO_ELEMENT_PATTERN, "")
312
+ .replace(STATE_PSEUDO_CLASS_PATTERN, "");
313
+ if (stripped === escaped) {
314
+ return selector;
315
+ }
316
+ // `.a > ::before` → `.a>*`; `.a:not(:hover)` → `.a`, not `.a:not(*)`,
317
+ // which would match nothing.
318
+ return stripped
319
+ .replace(COMBINATOR_SPACING_PATTERN, "$1")
320
+ .trim()
321
+ .replace(EMPTY_COMPOUND_PATTERN, "$1*")
322
+ .replace(NEGATED_ANY_PATTERN, "")
323
+ .replaceAll(ESCAPED_COLON_TOKEN, "\\:");
324
+ }
325
+
326
+ function selectorMatches(scope, selector) {
215
327
  try {
216
- rules = sheet.cssRules;
328
+ return scope.querySelector(selector) !== null;
217
329
  } catch {
218
- return null;
330
+ // Unknown to this browser's querySelector (a vendor pseudo-class, a
331
+ // newer syntax): it may well match, so it stays.
332
+ return true;
219
333
  }
220
- let out = "";
221
- for (const rule of rules) {
222
- const imported =
223
- rule.type === CSSRule.IMPORT_RULE && rule.styleSheet
224
- ? rulesText(rule.styleSheet, rule.styleSheet.href || base)
334
+ }
335
+
336
+ function cssVariables(text, into) {
337
+ for (const match of text.matchAll(CSS_VARIABLE_PATTERN)) {
338
+ into.add(match[1]);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * What the stylesheets of one capture keep, across every sheet and shadow
344
+ * root: custom properties inherit, so a variable one sheet defines another
345
+ * uses. Sheets are read into segments first (strings, or style rules with
346
+ * their custom property declarations set apart), and rendered once every
347
+ * sheet and inline style has said which variables it needs.
348
+ */
349
+ function createStyleCollector() {
350
+ /** Variables referred to outside a custom property's own value. */
351
+ const used = new Set();
352
+ /** Every kept value of each custom property, for the transitive pass. */
353
+ const definitions = new Map();
354
+ /** Per scope (document, shadow root): selector list → applies. */
355
+ const verdicts = new Map();
356
+
357
+ function ruleApplies(scope, selectorText) {
358
+ let cache = verdicts.get(scope);
359
+ if (!cache) {
360
+ cache = new Map();
361
+ verdicts.set(scope, cache);
362
+ }
363
+ let applies = cache.get(selectorText);
364
+ if (applies === undefined) {
365
+ applies = splitSelectorList(selectorText).some((part) => {
366
+ const tested = testedSelector(part);
367
+ return tested === null || selectorMatches(scope, tested);
368
+ });
369
+ cache.set(selectorText, applies);
370
+ }
371
+ return applies;
372
+ }
373
+
374
+ function keptText(text) {
375
+ cssVariables(text, used);
376
+ return text;
377
+ }
378
+
379
+ /** A kept style rule; a declaration whose text cannot be located in
380
+ * the rule's stays in it. */
381
+ function styleRuleSegment(rule, base) {
382
+ const text = absolutizeCss(rule.cssText, base);
383
+ const style = rule.style;
384
+ const declarations = [];
385
+ let rest = text;
386
+ for (let index = 0; index < (style ? style.length : 0); index++) {
387
+ const name = style[index];
388
+ if (!name.startsWith("--")) {
389
+ continue;
390
+ }
391
+ const value = style.getPropertyValue(name);
392
+ const important =
393
+ style.getPropertyPriority(name) === "important" ? " !important" : "";
394
+ const source = `${name}: ${value}${important};`;
395
+ if (!rest.includes(source)) {
396
+ continue;
397
+ }
398
+ rest = rest.replace(source, "");
399
+ declarations.push({ name, source });
400
+ const values = definitions.get(name) || [];
401
+ values.push(value);
402
+ definitions.set(name, values);
403
+ }
404
+ cssVariables(rest, used);
405
+ return declarations.length > 0 ? { text, declarations } : text;
406
+ }
407
+
408
+ function ruleListSegments(rules, base, scope) {
409
+ const segments = [];
410
+ for (const rule of rules) {
411
+ segments.push(...ruleSegments(rule, base, scope));
412
+ }
413
+ return segments;
414
+ }
415
+
416
+ function ruleSegments(rule, base, scope) {
417
+ if (rule.type === IMPORT_RULE) {
418
+ const imported = rule.styleSheet
419
+ ? sheetSegments(rule.styleSheet, rule.styleSheet.href || base, scope)
225
420
  : null;
226
- if (imported === null) {
227
- out += `${absolutizeCss(rule.cssText, base)}\n`;
228
- continue;
421
+ if (imported === null) {
422
+ return [keptText(absolutizeCss(rule.cssText, base))];
423
+ }
424
+ const media = rule.media?.mediaText;
425
+ return media ? [`@media ${media}{`, ...imported, "}"] : imported;
426
+ }
427
+ if (rule.type === STYLE_RULE) {
428
+ if (!ruleApplies(scope, rule.selectorText)) {
429
+ // Nested rules included: each one only matches within (or on) an
430
+ // element its parent matches.
431
+ return [];
432
+ }
433
+ // A nested block is not tested rule by rule: it stays whole.
434
+ return rule.cssRules?.length
435
+ ? [keptText(absolutizeCss(rule.cssText, base))]
436
+ : [styleRuleSegment(rule, base)];
437
+ }
438
+ if (rule.cssRules && rule.type !== KEYFRAMES_RULE) {
439
+ // A grouping rule: its prelude is whatever precedes its block.
440
+ const prelude = rule.cssText.slice(0, rule.cssText.indexOf("{"));
441
+ const inner = ruleListSegments(rule.cssRules, base, scope);
442
+ if (inner.length === 0 && !LAYER_PRELUDE_PATTERN.test(prelude)) {
443
+ return [];
444
+ }
445
+ return [`${prelude}{`, ...inner, "}"];
229
446
  }
230
- const media = rule.media?.mediaText;
231
- out += media ? `@media ${media}{${imported}}\n` : `${imported}\n`;
447
+ return [keptText(absolutizeCss(rule.cssText, base))];
232
448
  }
233
- return out;
449
+
450
+ /**
451
+ * The rules of a sheet worth keeping, as segments, tested against
452
+ * `scope` (the document, or the shadow root holding the sheet). Null
453
+ * when the browser refuses to read them (cross-origin sheet): the caller
454
+ * keeps a link then. Rules are read from the CSSOM, not from the
455
+ * element's text: CSS-in-JS libraries insert theirs with `insertRule`,
456
+ * leaving the `<style>` empty. Readable imports are inlined in place,
457
+ * under their media.
458
+ */
459
+ function sheetSegments(sheet, base, scope) {
460
+ let rules;
461
+ try {
462
+ rules = sheet.cssRules;
463
+ } catch {
464
+ return null;
465
+ }
466
+ return ruleListSegments(rules, base, scope);
467
+ }
468
+
469
+ /** Every variable reachable from what the capture uses. */
470
+ function reachableVariables() {
471
+ const reached = new Set(used);
472
+ const pending = [...used];
473
+ while (pending.length > 0) {
474
+ for (const value of definitions.get(pending.pop()) || []) {
475
+ const found = new Set();
476
+ cssVariables(value, found);
477
+ for (const name of found) {
478
+ if (!reached.has(name)) {
479
+ reached.add(name);
480
+ pending.push(name);
481
+ }
482
+ }
483
+ }
484
+ }
485
+ return reached;
486
+ }
487
+
488
+ function render(segments, reached) {
489
+ let out = "";
490
+ for (const segment of segments) {
491
+ if (typeof segment === "string") {
492
+ out += `${segment}\n`;
493
+ continue;
494
+ }
495
+ let text = segment.text;
496
+ for (const declaration of segment.declarations) {
497
+ if (!reached.has(declaration.name)) {
498
+ text = text.replace(declaration.source, "");
499
+ }
500
+ }
501
+ out += `${text}\n`;
502
+ }
503
+ return out;
504
+ }
505
+
506
+ return {
507
+ sheetSegments,
508
+ /** Variables an inline style or an attribute refers to. */
509
+ noteVariables: (text) => cssVariables(text, used),
510
+ /** Each sheet's final text; empty for one already rendered. */
511
+ finish(sheets) {
512
+ const reached = reachableVariables();
513
+ const seen = new Set();
514
+ return sheets.map((segments) => {
515
+ const css = render(segments, reached);
516
+ if (seen.has(css)) {
517
+ return "";
518
+ }
519
+ seen.add(css);
520
+ return css;
521
+ });
522
+ },
523
+ };
524
+ }
525
+
526
+ /** CSS as the text of a `<style>`: the character after a `<` that could
527
+ * open markup (a letter, `!`, `/`, `?`) is written as a CSS escape, the
528
+ * same token to CSS (`</style` in a string becomes `<\2f style`), so
529
+ * nothing in it closes the element and the server keeps the sheet. */
530
+ function styleText(css) {
531
+ return css.replace(
532
+ /<([a-z!/?])/gi,
533
+ (_match, next) => `<\\${next.charCodeAt(0).toString(16)} `,
534
+ );
234
535
  }
235
536
 
236
537
  function mediaAttribute(element) {
@@ -238,14 +539,36 @@
238
539
  return media ? ` media="${escapeAttribute(media)}"` : "";
239
540
  }
240
541
 
542
+ /** Sheets of the capture in progress, rendered at its end (see
543
+ * serializeDocument): their text depends on the whole document. */
544
+ let styles = null;
545
+ let pendingSheets = [];
546
+ let placeholderNonce = "";
547
+ /** The page's inner scroll container during a capture, null when the
548
+ * document scrolls. */
549
+ let scrollRootElement = null;
550
+
551
+ /** Comments are never serialized, and markup in text or attributes is
552
+ * escaped: the marker cannot occur otherwise. */
553
+ function sheetPlaceholder(segments, media) {
554
+ const index = pendingSheets.push({ segments, media }) - 1;
555
+ return `<!--dms-marketing-sheet-${placeholderNonce}-${index}-->`;
556
+ }
557
+
241
558
  function serializeStyle(element) {
242
559
  const sheet = element.sheet;
243
560
  if (sheet?.disabled) {
244
561
  return "";
245
562
  }
246
- const css = sheet ? rulesText(sheet, location.href) : null;
247
- const text = css === null ? element.textContent : css;
248
- return `<style${mediaAttribute(element)}>${text}</style>`;
563
+ const segments = sheet
564
+ ? styles.sheetSegments(sheet, location.href, element.getRootNode())
565
+ : null;
566
+ if (segments === null) {
567
+ const text = element.textContent;
568
+ styles.noteVariables(text);
569
+ return `<style${mediaAttribute(element)}>${styleText(text)}</style>`;
570
+ }
571
+ return sheetPlaceholder(segments, mediaAttribute(element));
249
572
  }
250
573
 
251
574
  /** Only stylesheets render; preloads, icons and manifests are requests
@@ -257,9 +580,11 @@
257
580
  return "";
258
581
  }
259
582
  const href = element.href;
260
- const css = sheet ? rulesText(sheet, href) : null;
261
- if (css !== null) {
262
- return `<style${mediaAttribute(element)}>${css}</style>`;
583
+ const segments = sheet
584
+ ? styles.sheetSegments(sheet, href, element.getRootNode())
585
+ : null;
586
+ if (segments !== null) {
587
+ return sheetPlaceholder(segments, mediaAttribute(element));
263
588
  }
264
589
  return `<link rel="stylesheet" href="${escapeAttribute(href)}"${mediaAttribute(element)}>`;
265
590
  }
@@ -267,7 +592,10 @@
267
592
  function adoptedStyles(root) {
268
593
  let out = "";
269
594
  for (const sheet of root.adoptedStyleSheets || []) {
270
- out += `<style>${rulesText(sheet, location.href) || ""}</style>`;
595
+ out += sheetPlaceholder(
596
+ styles.sheetSegments(sheet, location.href, root) || [],
597
+ "",
598
+ );
271
599
  }
272
600
  return out;
273
601
  }
@@ -281,6 +609,85 @@
281
609
  );
282
610
  }
283
611
 
612
+ function isOutOfFlow(position) {
613
+ return position === "fixed" || position === "absolute";
614
+ }
615
+
616
+ function inTopLayer(element) {
617
+ try {
618
+ return element.matches(TOP_LAYER_SELECTOR);
619
+ } catch {
620
+ // A browser without one of the pseudo-classes has no such layer.
621
+ return false;
622
+ }
623
+ }
624
+
625
+ /** A portal: the nearest positioned ancestor is fixed and hangs off
626
+ * `body` — where floating menus and popovers are mounted, unlike a fixed
627
+ * header, which lives in the app's own tree. */
628
+ function inFloatingPortal(element, positionOf, body) {
629
+ for (
630
+ let node = element.parentElement;
631
+ node && node !== body;
632
+ node = node.parentElement
633
+ ) {
634
+ const position = positionOf(node);
635
+ if (position !== "static") {
636
+ return position === "fixed" && node.parentElement === body;
637
+ }
638
+ }
639
+ return false;
640
+ }
641
+
642
+ function isLive(element) {
643
+ return (
644
+ LIVE_ROLES.has(element.getAttribute("role")) ||
645
+ LIVE_POLITENESS.has(element.getAttribute("aria-live"))
646
+ );
647
+ }
648
+
649
+ /**
650
+ * An overlay the visit opened over the page, not part of it:
651
+ * - anything in the top layer (a modal `<dialog>`, an open popover);
652
+ * - a dialog, alert dialog, tooltip, menu or listbox positioned out of
653
+ * the flow, or mounted in a fixed portal under `body`;
654
+ * - a live region (role status, alert or log, `aria-live`) fixed to the
655
+ * viewport: a toast, a notification;
656
+ * - a toast viewport: an element whose list child is fixed and holds the
657
+ * toasts (Reka / Nuxt UI, Radix, Sonner).
658
+ * In-flow content stays whatever its role: an inline `role="status"`
659
+ * badge, a listbox in a form, a menu in the page's own navigation.
660
+ * `positionOf` is the computed `position` of an element.
661
+ */
662
+ function isTransientOverlay(element, positionOf, body) {
663
+ if (inTopLayer(element)) {
664
+ return true;
665
+ }
666
+ if (OVERLAY_ROLES.has(element.getAttribute("role"))) {
667
+ return (
668
+ isOutOfFlow(positionOf(element)) ||
669
+ inFloatingPortal(element, positionOf, body)
670
+ );
671
+ }
672
+ const live = isLive(element);
673
+ if (live && positionOf(element) === "fixed") {
674
+ return true;
675
+ }
676
+ if (!live && element.getAttribute("role") !== "region") {
677
+ return false;
678
+ }
679
+ for (const child of element.children) {
680
+ if (LIST_TAGS.has(child.localName) && positionOf(child) === "fixed") {
681
+ return true;
682
+ }
683
+ }
684
+ return false;
685
+ }
686
+
687
+ function computedPosition(element) {
688
+ return getComputedStyle(element).position;
689
+ }
690
+
284
691
  function imageSource(element, value) {
285
692
  const current = element.currentSrc;
286
693
  if (current && !current.startsWith("data:")) {
@@ -339,8 +746,17 @@
339
746
  }
340
747
  value = LINK_TAGS.has(tag) ? withoutQuery(absolute, masked) : absolute;
341
748
  }
749
+ if (value.includes("var(")) {
750
+ styles.noteVariables(value);
751
+ }
342
752
  out += ` ${name}="${escapeAttribute(value)}"`;
343
753
  }
754
+ if (
755
+ element === scrollRootElement &&
756
+ !element.hasAttribute(SCROLL_ROOT_ATTRIBUTE)
757
+ ) {
758
+ out += ` ${SCROLL_ROOT_ATTRIBUTE}=""`;
759
+ }
344
760
  return out;
345
761
  }
346
762
 
@@ -406,6 +822,9 @@
406
822
  if (isMarked(element, BLOCK_MARKER)) {
407
823
  return serializeBlocked(element);
408
824
  }
825
+ if (isTransientOverlay(element, computedPosition, document.body)) {
826
+ return "";
827
+ }
409
828
  const mask =
410
829
  masked || isMarked(element, MASK_MARKER) || element.isContentEditable;
411
830
  if (tag === "canvas") {
@@ -432,7 +851,23 @@
432
851
  }
433
852
 
434
853
  function serializeDocument() {
435
- return `<!DOCTYPE html>${serializeElement(document.documentElement, false)}`;
854
+ styles = createStyleCollector();
855
+ pendingSheets = [];
856
+ placeholderNonce = Math.random().toString(36).slice(2);
857
+ const html = `<!DOCTYPE html>${serializeElement(document.documentElement, false)}`;
858
+ const css = styles.finish(pendingSheets.map((sheet) => sheet.segments));
859
+ const marker = new RegExp(
860
+ `<!--dms-marketing-sheet-${placeholderNonce}-([0-9]+)-->`,
861
+ "g",
862
+ );
863
+ const out = html.replace(marker, (_match, index) =>
864
+ css[index]
865
+ ? `<style${pendingSheets[index].media}>${styleText(css[index])}</style>`
866
+ : "",
867
+ );
868
+ styles = null;
869
+ pendingSheets = [];
870
+ return out;
436
871
  }
437
872
 
438
873
  // --- Timing ---------------------------------------------------------------
@@ -476,8 +911,25 @@
476
911
 
477
912
  // --- Upload ---------------------------------------------------------------
478
913
 
479
- // text/plain is CORS-safelisted (no preflight); no-cors keeps the
480
- // unreadable answer from logging an error on the visitor's console.
914
+ /** Gzipped where the browser can (the backend recognizes the gzip
915
+ * header), null otherwise: the upload then goes as plain JSON. */
916
+ async function gzip(text) {
917
+ if (typeof CompressionStream !== "function") {
918
+ return null;
919
+ }
920
+ try {
921
+ const stream = new Blob([text])
922
+ .stream()
923
+ .pipeThrough(new CompressionStream("gzip"));
924
+ return new Uint8Array(await new Response(stream).arrayBuffer());
925
+ } catch {
926
+ return null;
927
+ }
928
+ }
929
+
930
+ // text/plain is CORS-safelisted (no preflight), compressed or not; no-cors
931
+ // keeps the unreadable answer from logging an error on the visitor's
932
+ // console.
481
933
  function post(body) {
482
934
  fetch(context.endpoint, {
483
935
  method: "POST",
@@ -487,12 +939,33 @@
487
939
  }).catch(() => {});
488
940
  }
489
941
 
490
- function capture() {
942
+ /** The tracker's detection; a tracker older than this script has none,
943
+ * and the document is taken as scrolling. */
944
+ function findScrollRoot() {
945
+ try {
946
+ return typeof context.scrollRoot === "function"
947
+ ? context.scrollRoot() || null
948
+ : null;
949
+ } catch {
950
+ return null;
951
+ }
952
+ }
953
+
954
+ /** The whole content: the document, plus what the scroll root hides. */
955
+ function contentSize(root, scrollRoot) {
956
+ const hidden = scrollRoot
957
+ ? Math.max(scrollRoot.scrollHeight - scrollRoot.clientHeight, 0)
958
+ : 0;
959
+ return { width: root.scrollWidth, height: root.scrollHeight + hidden };
960
+ }
961
+
962
+ async function capture() {
491
963
  // The visitor navigated on since the negotiation: this is another page.
492
964
  if (location.pathname !== context.path) {
493
965
  return;
494
966
  }
495
967
  const root = document.documentElement;
968
+ scrollRootElement = findScrollRoot();
496
969
  const page = {
497
970
  website: context.website,
498
971
  token: config.token,
@@ -508,20 +981,38 @@
508
981
  const html = serializeDocument();
509
982
  const body = JSON.stringify({
510
983
  ...page,
511
- document: { width: root.scrollWidth, height: root.scrollHeight },
984
+ document: contentSize(root, scrollRootElement),
512
985
  html,
513
986
  });
514
- if (new Blob([body]).size <= config.maxBytes) {
515
- post(body);
987
+ scrollRootElement = null;
988
+ // The cap applies to what travels: the compressed upload when there is
989
+ // one.
990
+ const compressed = await gzip(body);
991
+ const size = compressed ? compressed.byteLength : new Blob([body]).size;
992
+ if (size <= config.maxBytes) {
993
+ post(
994
+ compressed ? new Blob([compressed], { type: "text/plain" }) : body,
995
+ );
516
996
  return;
517
997
  }
518
998
  post(
519
999
  JSON.stringify({
520
1000
  ...page,
521
- oversizeBytes: Math.min(new Blob([html]).size, MAX_REPORTED_BYTES),
1001
+ oversizeBytes: Math.min(size, MAX_REPORTED_BYTES),
522
1002
  }),
523
1003
  );
524
1004
  }
525
1005
 
526
- whenSettled(() => whenIdle(capture));
1006
+ if (isModule) {
1007
+ module.exports = {
1008
+ contentSize,
1009
+ createStyleCollector,
1010
+ isTransientOverlay,
1011
+ splitSelectorList,
1012
+ styleText,
1013
+ testedSelector,
1014
+ };
1015
+ } else if (asked) {
1016
+ whenSettled(() => whenIdle(() => void capture().catch(() => {})));
1017
+ }
527
1018
  })();