@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/README.md +1 -0
- package/dist/holdy.js +2 -2
- package/dist/holdy.js.map +4 -4
- package/dist/index.cjs +126 -3
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +126 -3
- package/dist/index.mjs.map +4 -4
- package/dist/skeleton.d.ts +50 -0
- package/dist/skeleton.d.ts.map +1 -0
- package/dist/web.d.ts +8 -0
- package/dist/web.d.ts.map +1 -1
- package/dist/web.mjs +126 -3
- package/dist/web.mjs.map +4 -4
- package/package.json +1 -1
- package/src/skeleton.test.ts +60 -0
- package/src/skeleton.ts +173 -0
- package/src/web.ts +20 -1
package/package.json
CHANGED
|
@@ -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
|
+
})
|
package/src/skeleton.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|