Skip to content

Commit 1f76423

Browse files
bkboothclaude
andcommitted
feat(audience): scaffold @imtbl/pixel package with attribution, loader, and snippet
Add the pixel package scaffold and three self-contained modules that have no dependency on PR #2824. The package builds to a single IIFE bundle (dist/imtbl.js) targeting <10KB gzipped (currently 823 bytes). Modules: - attribution: UTM params, ad click IDs, referrer, landing page (session-cached) - loader: command-queue pattern (window.__imtbl) with pre-load replay - snippet: embeddable <script> tag generator for studio integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a684a42 commit 1f76423

15 files changed

Lines changed: 581 additions & 52 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module.exports = {
2+
extends: ['../../../.eslintrc'],
3+
parserOptions: {
4+
project: './tsconfig.eslint.json',
5+
tsconfigRootDir: __dirname,
6+
},
7+
rules: {
8+
'no-underscore-dangle': ['error', { allow: ['__imtbl', '_loaded'] }],
9+
},
10+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Config } from 'jest';
2+
3+
const config: Config = {
4+
roots: ['<rootDir>/src'],
5+
moduleDirectories: ['node_modules', 'src'],
6+
testEnvironment: 'jsdom',
7+
transform: {
8+
'^.+\\.(t|j)sx?$': '@swc/jest',
9+
},
10+
transformIgnorePatterns: [],
11+
};
12+
13+
export default config;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "@imtbl/pixel",
3+
"description": "Immutable Tracking Pixel — drop-in JavaScript snippet for device fingerprint, page view, and attribution data",
4+
"version": "0.0.0",
5+
"author": "Immutable",
6+
"private": true,
7+
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
8+
"dependencies": {},
9+
"devDependencies": {
10+
"@swc/core": "^1.4.2",
11+
"@swc/jest": "^0.2.37",
12+
"@types/jest": "^29.5.12",
13+
"eslint": "^8.56.0",
14+
"jest": "^29.7.0",
15+
"jest-environment-jsdom": "^29.4.3",
16+
"tsup": "^8.3.0",
17+
"typescript": "^5.6.2"
18+
},
19+
"exports": {
20+
"development": {
21+
"types": "./src/index.ts",
22+
"default": "./dist/imtbl.js"
23+
},
24+
"default": {
25+
"types": "./dist/types/index.d.ts",
26+
"default": "./dist/imtbl.js"
27+
}
28+
},
29+
"files": ["dist"],
30+
"main": "dist/imtbl.js",
31+
"homepage": "https://github.com/immutable/ts-immutable-sdk#readme",
32+
"repository": "immutable/ts-immutable-sdk.git",
33+
"scripts": {
34+
"build": "pnpm transpile && pnpm typegen",
35+
"transpile": "tsup",
36+
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
37+
"lint": "eslint ./src --ext .ts --max-warnings=0",
38+
"test": "jest --passWithNoTests",
39+
"typecheck": "tsc --customConditions default --noEmit"
40+
},
41+
"type": "module",
42+
"types": "./dist/types/index.d.ts"
43+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { collectAttribution, clearAttribution } from './attribution';
2+
3+
const STORAGE_KEY = '__imtbl_attribution';
4+
5+
beforeEach(() => {
6+
sessionStorage.clear();
7+
jest.restoreAllMocks();
8+
});
9+
10+
function setLocation(url: string) {
11+
Object.defineProperty(window, 'location', {
12+
value: new URL(url),
13+
writable: true,
14+
configurable: true,
15+
});
16+
}
17+
18+
describe('collectAttribution', () => {
19+
it('parses UTM parameters from the URL', () => {
20+
setLocation(
21+
'https://example.com/?utm_source=google&utm_medium=cpc&utm_campaign=spring&utm_content=banner&utm_term=nft',
22+
);
23+
24+
const result = collectAttribution();
25+
expect(result.utm_source).toBe('google');
26+
expect(result.utm_medium).toBe('cpc');
27+
expect(result.utm_campaign).toBe('spring');
28+
expect(result.utm_content).toBe('banner');
29+
expect(result.utm_term).toBe('nft');
30+
});
31+
32+
it('parses ad network click IDs', () => {
33+
setLocation(
34+
'https://example.com/?gclid=abc123&fbclid=fb456&ttclid=tt789&msclkid=ms000',
35+
);
36+
37+
const result = collectAttribution();
38+
expect(result.gclid).toBe('abc123');
39+
expect(result.fbclid).toBe('fb456');
40+
expect(result.ttclid).toBe('tt789');
41+
expect(result.msclkid).toBe('ms000');
42+
});
43+
44+
it('captures referrer and landing page', () => {
45+
setLocation('https://game.example.com/landing');
46+
Object.defineProperty(document, 'referrer', {
47+
value: 'https://google.com/search?q=nft',
48+
configurable: true,
49+
});
50+
51+
const result = collectAttribution();
52+
expect(result.referrer).toBe('https://google.com/search?q=nft');
53+
expect(result.landing_page).toBe('https://game.example.com/landing');
54+
});
55+
56+
it('caches in sessionStorage and returns cached on second call', () => {
57+
setLocation('https://example.com/?utm_source=google');
58+
59+
const first = collectAttribution();
60+
expect(first.utm_source).toBe('google');
61+
62+
// Change URL — should still return cached value
63+
setLocation('https://example.com/?utm_source=facebook');
64+
const second = collectAttribution();
65+
expect(second.utm_source).toBe('google');
66+
});
67+
68+
it('returns empty attribution when no params are present', () => {
69+
setLocation('https://example.com/');
70+
Object.defineProperty(document, 'referrer', { value: '', configurable: true });
71+
72+
const result = collectAttribution();
73+
expect(result.utm_source).toBeUndefined();
74+
expect(result.gclid).toBeUndefined();
75+
expect(result.referrer).toBeUndefined();
76+
});
77+
78+
it('handles sessionStorage being unavailable', () => {
79+
setLocation('https://example.com/?utm_source=twitter');
80+
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
81+
throw new Error('storage disabled');
82+
});
83+
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
84+
throw new Error('storage disabled');
85+
});
86+
87+
const result = collectAttribution();
88+
expect(result.utm_source).toBe('twitter');
89+
});
90+
});
91+
92+
describe('clearAttribution', () => {
93+
it('removes cached attribution from sessionStorage', () => {
94+
setLocation('https://example.com/?utm_source=google');
95+
collectAttribution();
96+
expect(sessionStorage.getItem(STORAGE_KEY)).not.toBeNull();
97+
98+
clearAttribution();
99+
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
100+
});
101+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
const UTM_PARAMS = [
2+
'utm_source',
3+
'utm_medium',
4+
'utm_campaign',
5+
'utm_content',
6+
'utm_term',
7+
] as const;
8+
9+
const CLICK_ID_PARAMS = [
10+
'gclid',
11+
'fbclid',
12+
'ttclid',
13+
'msclkid',
14+
] as const;
15+
16+
const STORAGE_KEY = '__imtbl_attribution';
17+
18+
export interface Attribution {
19+
utm_source?: string;
20+
utm_medium?: string;
21+
utm_campaign?: string;
22+
utm_content?: string;
23+
utm_term?: string;
24+
gclid?: string;
25+
fbclid?: string;
26+
ttclid?: string;
27+
msclkid?: string;
28+
referrer?: string;
29+
landing_page?: string;
30+
}
31+
32+
type AttributionKey = keyof Attribution;
33+
34+
function parseParams(url: string): Attribution {
35+
let params: URLSearchParams;
36+
try {
37+
params = new URL(url).searchParams;
38+
} catch {
39+
return {};
40+
}
41+
42+
const result: Attribution = {};
43+
for (const key of [...UTM_PARAMS, ...CLICK_ID_PARAMS]) {
44+
const value = params.get(key);
45+
if (value) {
46+
result[key as AttributionKey] = value;
47+
}
48+
}
49+
return result;
50+
}
51+
52+
function loadFromStorage(): Attribution | null {
53+
try {
54+
const raw = sessionStorage.getItem(STORAGE_KEY);
55+
return raw ? (JSON.parse(raw) as Attribution) : null;
56+
} catch {
57+
return null;
58+
}
59+
}
60+
61+
function saveToStorage(attribution: Attribution): void {
62+
try {
63+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(attribution));
64+
} catch {
65+
// sessionStorage may be unavailable (private browsing, storage full)
66+
}
67+
}
68+
69+
export function collectAttribution(): Attribution {
70+
const cached = loadFromStorage();
71+
if (cached) return cached;
72+
73+
const urlParams = typeof window !== 'undefined' && window.location
74+
? parseParams(window.location.href)
75+
: {};
76+
77+
const referrer = typeof document !== 'undefined' ? document.referrer || undefined : undefined;
78+
const landingPage = typeof window !== 'undefined' && window.location
79+
? window.location.href
80+
: undefined;
81+
82+
const attribution: Attribution = {
83+
...urlParams,
84+
referrer,
85+
landing_page: landingPage,
86+
};
87+
88+
saveToStorage(attribution);
89+
return attribution;
90+
}
91+
92+
export function clearAttribution(): void {
93+
try {
94+
sessionStorage.removeItem(STORAGE_KEY);
95+
} catch {
96+
// noop
97+
}
98+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export { collectAttribution, clearAttribution } from './attribution';
2+
export type { Attribution } from './attribution';
3+
4+
export { createLoader } from './loader';
5+
export type { Command, ImtblGlobal } from './loader';
6+
7+
export { generateSnippet } from './snippet';
8+
export type { SnippetOptions } from './snippet';
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { createLoader, Command } from './loader';
2+
3+
beforeEach(() => {
4+
delete (window as Record<string, unknown>).__imtbl;
5+
});
6+
7+
describe('createLoader', () => {
8+
it('installs on window.__imtbl', () => {
9+
const handler = jest.fn();
10+
const loader = createLoader(handler);
11+
12+
expect((window as Record<string, unknown>).__imtbl).toBe(loader);
13+
expect(loader._loaded).toBe(true);
14+
});
15+
16+
it('forwards push() calls to the handler', () => {
17+
const handler = jest.fn();
18+
const loader = createLoader(handler);
19+
20+
loader.push(['init', { key: 'pk_123' }]);
21+
loader.push(['identify', 'user-1']);
22+
23+
expect(handler).toHaveBeenCalledTimes(2);
24+
expect(handler).toHaveBeenCalledWith(['init', { key: 'pk_123' }]);
25+
expect(handler).toHaveBeenCalledWith(['identify', 'user-1']);
26+
});
27+
28+
it('replays queued commands from the stub array', () => {
29+
// Simulate the snippet having pushed commands before the script loaded
30+
const stub: Command[] = [
31+
['init', { key: 'pk_abc' }],
32+
['consent', 'anonymous'],
33+
];
34+
(window as Record<string, unknown>).__imtbl = stub;
35+
36+
const handler = jest.fn();
37+
createLoader(handler);
38+
39+
expect(handler).toHaveBeenCalledTimes(2);
40+
expect(handler).toHaveBeenNthCalledWith(1, ['init', { key: 'pk_abc' }]);
41+
expect(handler).toHaveBeenNthCalledWith(2, ['consent', 'anonymous']);
42+
});
43+
44+
it('replays queued commands then handles new pushes', () => {
45+
(window as Record<string, unknown>).__imtbl = [['init', { key: 'pk_1' }]];
46+
47+
const handler = jest.fn();
48+
const loader = createLoader(handler);
49+
50+
// Queued command replayed
51+
expect(handler).toHaveBeenCalledTimes(1);
52+
53+
// New command via push
54+
loader.push(['identify', 'user-2']);
55+
expect(handler).toHaveBeenCalledTimes(2);
56+
expect(handler).toHaveBeenLastCalledWith(['identify', 'user-2']);
57+
});
58+
59+
it('handles empty window.__imtbl gracefully', () => {
60+
(window as Record<string, unknown>).__imtbl = [];
61+
const handler = jest.fn();
62+
createLoader(handler);
63+
expect(handler).not.toHaveBeenCalled();
64+
});
65+
66+
it('handles undefined window.__imtbl gracefully', () => {
67+
const handler = jest.fn();
68+
createLoader(handler);
69+
expect(handler).not.toHaveBeenCalled();
70+
});
71+
72+
it('supports multiple commands in a single push call', () => {
73+
const handler = jest.fn();
74+
const loader = createLoader(handler);
75+
76+
loader.push(['init', { key: 'pk_1' }], ['consent', 'full']);
77+
expect(handler).toHaveBeenCalledTimes(2);
78+
});
79+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export type Command = [string, ...unknown[]];
2+
3+
export interface ImtblGlobal {
4+
push: (...commands: Command[]) => void;
5+
_loaded: boolean;
6+
}
7+
8+
type CommandHandler = (command: Command) => void;
9+
10+
export function createLoader(handler: CommandHandler): ImtblGlobal {
11+
const win = typeof window !== 'undefined' ? window : undefined;
12+
const existing = win ? (win as unknown as Record<string, unknown>).__imtbl : undefined;
13+
14+
// Replay any commands that were queued before the script loaded
15+
const queued: Command[] = Array.isArray(existing) ? (existing as Command[]) : [];
16+
17+
const loader: ImtblGlobal = {
18+
push: (...commands: Command[]) => {
19+
for (const cmd of commands) {
20+
handler(cmd);
21+
}
22+
},
23+
_loaded: true,
24+
};
25+
26+
// Install on window
27+
if (win) {
28+
(win as unknown as Record<string, unknown>).__imtbl = loader;
29+
}
30+
31+
// Replay queued commands in order
32+
for (const cmd of queued) {
33+
handler(cmd);
34+
}
35+
36+
return loader;
37+
}

0 commit comments

Comments
 (0)