@holdyapp/sdk 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holdyapp/sdk",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Holdy SDK · track/identify/group/page → POST /api/v1/events (entry-contract v1) · auto-capture web",
5
5
  "license": "MIT",
6
6
  "homepage": "https://holdy.chat",
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { collectSkeleton, describeSkeletonElement, fnv1a, SKELETON_MAX_ELEMENTS, type SkeletonElementLike } from './skeleton'
3
+
4
+ function el(tag: string, attrs: Record<string, string> = {}, rect = { x: 10, y: 20, width: 100, height: 40 }): SkeletonElementLike {
5
+ return {
6
+ tagName: tag.toUpperCase(),
7
+ getAttribute: (name) => attrs[name] ?? null,
8
+ getBoundingClientRect: () => rect,
9
+ }
10
+ }
11
+
12
+ const viewport = { width: 1000, height: 2000, scrollY: 0 }
13
+
14
+ describe('fnv1a', () => {
15
+ it('é estável e conhecido', () => {
16
+ expect(fnv1a('')).toBe('811c9dc5')
17
+ expect(fnv1a('a')).toBe('e40c292c')
18
+ expect(fnv1a('holdy')).toBe(fnv1a('holdy'))
19
+ })
20
+ })
21
+
22
+ describe('describeSkeletonElement', () => {
23
+ it('guarda tag, role, data-testid, name, aria-label e tipo, nunca texto nem valor', () => {
24
+ const out = describeSkeletonElement(
25
+ el('input', { type: 'Email', name: 'email', 'aria-label': ' Seu e-mail ', value: 'segredo@x.com', placeholder: 'Digite' }),
26
+ viewport,
27
+ )
28
+ expect(out).toEqual({ t: 'input', n: 'email', l: 'Seu e-mail', k: 'email', b: [1, 1, 10, 2] })
29
+ expect(JSON.stringify(out)).not.toContain('segredo')
30
+ })
31
+
32
+ it('sem viewport não há geometria', () => {
33
+ expect(describeSkeletonElement(el('button', { 'data-testid': 'continue' }), null)).toEqual({ t: 'button', id: 'continue' })
34
+ })
35
+ })
36
+
37
+ describe('collectSkeleton', () => {
38
+ const root = (items: SkeletonElementLike[]) => ({ querySelectorAll: () => items })
39
+
40
+ it('o hash ignora a geometria e muda com a estrutura', () => {
41
+ const a = collectSkeleton(root([el('button', { 'data-testid': 'go' }, { x: 0, y: 0, width: 50, height: 20 })]), '/x', viewport)
42
+ const b = collectSkeleton(root([el('button', { 'data-testid': 'go' }, { x: 300, y: 900, width: 80, height: 30 })]), '/x', viewport)
43
+ const c = collectSkeleton(root([el('button', { 'data-testid': 'stop' })]), '/x', viewport)
44
+ expect(a.hash).toBe(b.hash)
45
+ expect(a.hash).not.toBe(c.hash)
46
+ expect(a.elements[0].b).toEqual([0, 0, 5, 1])
47
+ })
48
+
49
+ it('deixa fora input hidden e elemento sem tamanho, e conta o total antes do teto', () => {
50
+ const many = Array.from({ length: SKELETON_MAX_ELEMENTS + 10 }, (_, i) => el('a', { href: `/p/${i}` }))
51
+ const out = collectSkeleton(
52
+ root([el('input', { type: 'hidden', name: 'csrf' }), el('button', {}, { x: 0, y: 0, width: 0, height: 0 }), ...many]),
53
+ '/lista',
54
+ viewport,
55
+ )
56
+ expect(out.count).toBe(SKELETON_MAX_ELEMENTS + 10)
57
+ expect(out.elements).toHaveLength(SKELETON_MAX_ELEMENTS)
58
+ expect(out.elements.every((e) => e.t === 'a')).toBe(true)
59
+ })
60
+ })
@@ -0,0 +1,173 @@
1
+ export const SKELETON_SELECTOR =
2
+ 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [data-testid]'
3
+ export const SKELETON_MAX_ELEMENTS = 80
4
+ export const SKELETON_DELAY_MS = 1500
5
+ const SENT_STORAGE_KEY = 'holdy_skeletons'
6
+ const SENT_STORAGE_MAX = 200
7
+
8
+ export interface SkeletonRect {
9
+ x: number
10
+ y: number
11
+ width: number
12
+ height: number
13
+ }
14
+
15
+ export interface SkeletonElementLike {
16
+ tagName: string
17
+ getAttribute(name: string): string | null
18
+ getBoundingClientRect?(): SkeletonRect
19
+ }
20
+
21
+ export interface SkeletonRootLike {
22
+ querySelectorAll(selector: string): ArrayLike<SkeletonElementLike>
23
+ }
24
+
25
+ export interface SkeletonViewport {
26
+ width: number
27
+ height: number
28
+ scrollY: number
29
+ }
30
+
31
+ export interface SkeletonElement {
32
+ t: string
33
+ r?: string
34
+ id?: string
35
+ n?: string
36
+ l?: string
37
+ k?: string
38
+ b?: [number, number, number, number]
39
+ }
40
+
41
+ export interface PageSkeleton {
42
+ path: string
43
+ hash: string
44
+ count: number
45
+ elements: SkeletonElement[]
46
+ }
47
+
48
+ export function fnv1a(input: string): string {
49
+ let hash = 0x811c9dc5
50
+ for (let i = 0; i < input.length; i++) {
51
+ hash ^= input.charCodeAt(i)
52
+ hash = Math.imul(hash, 0x01000193) >>> 0
53
+ }
54
+ return hash.toString(16).padStart(8, '0')
55
+ }
56
+
57
+ function attr(el: SkeletonElementLike, name: string, max: number): string | undefined {
58
+ const value = el.getAttribute(name)
59
+ if (!value) return undefined
60
+ const trimmed = value.replace(/\s+/g, ' ').trim().slice(0, max)
61
+ return trimmed || undefined
62
+ }
63
+
64
+ function isVisible(el: SkeletonElementLike): boolean {
65
+ if (!el.getBoundingClientRect) return true
66
+ const rect = el.getBoundingClientRect()
67
+ return rect.width > 0 && rect.height > 0
68
+ }
69
+
70
+ function relativeBox(rect: SkeletonRect, viewport: SkeletonViewport): [number, number, number, number] {
71
+ const pct = (value: number, base: number) => (base > 0 ? Math.max(0, Math.min(100, Math.round((value / base) * 100))) : 0)
72
+ return [pct(rect.x, viewport.width), pct(rect.y + viewport.scrollY, viewport.height), pct(rect.width, viewport.width), pct(rect.height, viewport.height)]
73
+ }
74
+
75
+ export function describeSkeletonElement(el: SkeletonElementLike, viewport: SkeletonViewport | null): SkeletonElement {
76
+ const out: SkeletonElement = { t: el.tagName.toLowerCase() }
77
+ const role = attr(el, 'role', 40)
78
+ if (role) out.r = role
79
+ const testid = attr(el, 'data-testid', 80)
80
+ if (testid) out.id = testid
81
+ const name = attr(el, 'name', 80)
82
+ if (name) out.n = name
83
+ const label = attr(el, 'aria-label', 80)
84
+ if (label) out.l = label
85
+ if (out.t === 'input' || out.t === 'button') {
86
+ const type = attr(el, 'type', 20)
87
+ if (type) out.k = type.toLowerCase()
88
+ }
89
+ if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport)
90
+ return out
91
+ }
92
+
93
+ export function collectSkeleton(root: SkeletonRootLike, path: string, viewport: SkeletonViewport | null): PageSkeleton {
94
+ const found = root.querySelectorAll(SKELETON_SELECTOR)
95
+ const visible: SkeletonElementLike[] = []
96
+ for (let i = 0; i < found.length; i++) {
97
+ const el = found[i]
98
+ if (el.tagName.toLowerCase() === 'input' && (el.getAttribute('type') ?? '').toLowerCase() === 'hidden') continue
99
+ if (!isVisible(el)) continue
100
+ visible.push(el)
101
+ }
102
+ const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport))
103
+ const structural = elements.map(({ b: _b, ...rest }) => rest)
104
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements }
105
+ }
106
+
107
+ export interface SkeletonCaptureOptions {
108
+ onSkeleton: (skeleton: PageSkeleton) => void
109
+ delayMs?: number
110
+ }
111
+
112
+ export interface SkeletonCapture {
113
+ onNavigate: (path: string) => void
114
+ teardown: () => void
115
+ }
116
+
117
+ function loadSent(): Set<string> {
118
+ try {
119
+ const raw = sessionStorage.getItem(SENT_STORAGE_KEY)
120
+ const list = raw ? (JSON.parse(raw) as unknown) : []
121
+ return new Set(Array.isArray(list) ? list.filter((x): x is string => typeof x === 'string') : [])
122
+ } catch {
123
+ return new Set()
124
+ }
125
+ }
126
+
127
+ function saveSent(sent: Set<string>): void {
128
+ try {
129
+ sessionStorage.setItem(SENT_STORAGE_KEY, JSON.stringify([...sent].slice(-SENT_STORAGE_MAX)))
130
+ } catch {
131
+ return
132
+ }
133
+ }
134
+
135
+ export function installSkeletonCapture(opts: SkeletonCaptureOptions): SkeletonCapture {
136
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
137
+ return { onNavigate: () => undefined, teardown: () => undefined }
138
+ }
139
+ const delay = opts.delayMs ?? SKELETON_DELAY_MS
140
+ const sent = loadSent()
141
+ let pending: ReturnType<typeof setTimeout> | null = null
142
+
143
+ const capture = (path: string): void => {
144
+ pending = null
145
+ if (location.pathname !== path) return
146
+ try {
147
+ const viewport: SkeletonViewport = {
148
+ width: window.innerWidth,
149
+ height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
150
+ scrollY: window.scrollY,
151
+ }
152
+ const skeleton = collectSkeleton(document, path, viewport)
153
+ const key = `${path}:${skeleton.hash}`
154
+ if (sent.has(key)) return
155
+ sent.add(key)
156
+ saveSent(sent)
157
+ opts.onSkeleton(skeleton)
158
+ } catch {
159
+ return
160
+ }
161
+ }
162
+
163
+ return {
164
+ onNavigate: (path: string) => {
165
+ if (pending) clearTimeout(pending)
166
+ pending = setTimeout(() => capture(path), delay)
167
+ },
168
+ teardown: () => {
169
+ if (pending) clearTimeout(pending)
170
+ pending = null
171
+ },
172
+ }
173
+ }
package/src/web.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import { HoldyCore } from './core'
15
15
  import { installAutoCapture, type NavigationKind } from './auto-capture'
16
16
  import { installFrictionCapture, type FrictionCapture, type FrictionCaptureOptions } from './friction'
17
+ import { installSkeletonCapture, type SkeletonCapture } from './skeleton'
17
18
  import { buildWebContext } from './web-context'
18
19
  import { installProductChanges, type ProductChangesHandle } from './changes'
19
20
  import type { HoldyOptions, Properties } from './types'
@@ -40,12 +41,20 @@ export interface HoldyWebOptions extends HoldyOptions {
40
41
  * identificada (`group()` ou `accountKey`) nada é aplicado.
41
42
  */
42
43
  productChanges?: boolean
44
+ /**
45
+ * Mandar o esqueleto da página · default true. Uma vez por (rota, estrutura)
46
+ * por aba: `$page_skeleton` com os elementos interativos (tag, role,
47
+ * data-testid, name, aria-label, tipo, posição relativa). Sem texto livre,
48
+ * sem valor de input, sem pixel.
49
+ */
50
+ skeleton?: boolean
43
51
  }
44
52
 
45
53
  export class HoldyWeb extends HoldyCore {
46
54
  private teardownAutoCapture: (() => void) | null = null
47
55
  private friction: FrictionCapture | null = null
48
56
  private changes: ProductChangesHandle | null = null
57
+ private skeleton: SkeletonCapture | null = null
49
58
 
50
59
  constructor(opts: HoldyWebOptions) {
51
60
  super('sdk_web', opts)
@@ -67,12 +76,20 @@ export class HoldyWeb extends HoldyCore {
67
76
  })
68
77
  }
69
78
 
79
+ if (opts.skeleton !== false) {
80
+ this.skeleton = installSkeletonCapture({
81
+ onSkeleton: (skeleton) => this.track('$page_skeleton', { ...skeleton }),
82
+ })
83
+ }
84
+
70
85
  if (opts.autoCapture !== false) {
71
86
  this.teardownAutoCapture = installAutoCapture({
72
87
  onNavigate: (kind: NavigationKind) => {
73
88
  this.page(undefined, { navigation: kind })
74
- this.friction?.onNavigate(typeof location !== 'undefined' ? location.pathname : '/')
89
+ const path = typeof location !== 'undefined' ? location.pathname : '/'
90
+ this.friction?.onNavigate(path)
75
91
  this.changes?.onNavigate()
92
+ this.skeleton?.onNavigate(path)
76
93
  },
77
94
  })
78
95
  }
@@ -128,6 +145,8 @@ export class HoldyWeb extends HoldyCore {
128
145
  this.friction = null
129
146
  this.changes?.teardown()
130
147
  this.changes = null
148
+ this.skeleton?.teardown()
149
+ this.skeleton = null
131
150
  await super.shutdown()
132
151
  }
133
152