@99ways/posthog-page-experiments 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ ## [0.1.0] - 2026-09-17
6
+
7
+ ### Added
8
+
9
+ - Code-first A/B and multivariate page execution backed by PostHog feature flags.
10
+ - Eligibility checks that run before feature-flag evaluation.
11
+ - Declarative DOM updates and ordered custom functions.
12
+ - URL variant overrides for QA.
13
+ - Configurable fallback, timeout, and debug behavior.
14
+ - ESM, browser/IIFE, and TypeScript declaration builds.
15
+ - Unit and real-browser tests using an actual `posthog-js` client.
16
+
17
+ [0.1.0]: https://github.com/99ways-io/posthog-page-experiments/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 99Ways
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,252 @@
1
+ # PostHog Page Experiments
2
+
3
+ Code-first page experiments for PostHog. Apply A/B and multivariate variants to existing websites with eligibility checks, URL QA overrides, and safe fallbacks.
4
+
5
+ Use it when PostHog should handle assignment and analysis, but the tested experience lives in an existing page that you need to change with JavaScript or targeted DOM updates.
6
+
7
+ > [!NOTE]
8
+ > This is an independent community project from [99Ways](https://99ways.io). It is not affiliated with or endorsed by PostHog.
9
+
10
+ ## Why it exists
11
+
12
+ A direct `posthog.getFeatureFlag()` call is enough for a simple product branch. Page experiments tend to repeat more browser-side work:
13
+
14
+ - wait for the page and PostHog to become ready;
15
+ - exclude visitors who cannot receive the change before recording an exposure;
16
+ - map variants and apply several DOM or custom-code steps;
17
+ - fall back safely when a flag is late, disabled, or misconfigured;
18
+ - let reviewers force a variant through the URL.
19
+
20
+ PostHog Page Experiments packages that execution logic without replacing PostHog's feature flags, experiment analysis, or no-code tools.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install @99ways/posthog-page-experiments posthog-js
26
+ ```
27
+
28
+ The package has no runtime dependencies. It works with an existing PostHog browser client, whether you pass that client directly or expose it as `window.posthog`.
29
+
30
+ ## Quick start
31
+
32
+ ```ts
33
+ import { runExperiment } from '@99ways/posthog-page-experiments'
34
+ import posthog from 'posthog-js'
35
+
36
+ posthog.init('YOUR_PROJECT_KEY', {
37
+ api_host: 'https://us.i.posthog.com',
38
+ })
39
+
40
+ const activeVariant = await runExperiment('pricing-page-headline', {
41
+ posthog,
42
+
43
+ // Checked before PostHog evaluates the flag. Ineligible visitors return null
44
+ // and are not included in the experiment by this call.
45
+ isEligible: () => document.querySelector('.pricing-hero') !== null,
46
+
47
+ variants: {
48
+ control: [],
49
+ test: [
50
+ {
51
+ selector: '.pricing-hero h1',
52
+ updates: {
53
+ innerText: 'A clearer reason to choose the product',
54
+ },
55
+ },
56
+ ],
57
+ },
58
+ })
59
+
60
+ console.log(activeVariant) // 'control', 'test', another configured variant, or null
61
+ ```
62
+
63
+ The default variant must be present in `variants`. An empty `control: []` keeps the original page unchanged while making fallback behavior explicit.
64
+
65
+ ### Direct browser script
66
+
67
+ After `0.1.0` is published, the minified browser build will be available from npm CDNs:
68
+
69
+ ```html
70
+ <script src="https://cdn.jsdelivr.net/npm/@99ways/posthog-page-experiments@0.1.0/dist/posthog-page-experiments.min.js"></script>
71
+ <script>
72
+ window.runExperiment('pricing-page-headline', {
73
+ isEligible: () => document.querySelector('.pricing-hero') !== null,
74
+ variants: {
75
+ control: [],
76
+ test: [
77
+ {
78
+ selector: '.pricing-hero h1',
79
+ updates: { innerText: 'A clearer reason to choose the product' },
80
+ },
81
+ ],
82
+ },
83
+ })
84
+ </script>
85
+ ```
86
+
87
+ Load PostHog before the experiment code, or let the package wait up to `featureFlagTimeoutMs` for a late-loading `window.posthog` client.
88
+
89
+ ## Protect experiment validity with eligibility
90
+
91
+ PostHog records an experiment exposure when your code evaluates the flag. If a visitor cannot receive the page change—because the relevant page, state, or element is absent—evaluate that condition first:
92
+
93
+ ```ts
94
+ runExperiment('checkout-reassurance', {
95
+ isEligible: async () => {
96
+ const checkout = document.querySelector('[data-checkout]')
97
+ const hasExistingOrder = await hasCompletedOrder()
98
+ return checkout !== null && !hasExistingOrder
99
+ },
100
+ variants: {
101
+ control: [],
102
+ test: [
103
+ {
104
+ selector: '[data-checkout-reassurance]',
105
+ updates: { innerText: 'Free returns within 30 days' },
106
+ },
107
+ ],
108
+ },
109
+ })
110
+ ```
111
+
112
+ `isEligible` runs after `DOMContentLoaded` and before the URL override or PostHog is read. It may be synchronous or asynchronous. A false result resolves `runExperiment()` to `null`; an error rejects the promise without evaluating the flag.
113
+
114
+ Eligibility should describe who can actually receive the tested experience. It is not a replacement for audience targeting in PostHog.
115
+
116
+ ## QA a variant through the URL
117
+
118
+ Add a query parameter whose name is the feature-flag key:
119
+
120
+ ```text
121
+ https://example.com/pricing?pricing-page-headline=test
122
+ ```
123
+
124
+ A configured URL variant takes precedence over PostHog, which makes review possible before a rollout exists. Eligibility still runs first. An unknown variant falls back to the configured default.
125
+
126
+ ## Choose the right tool
127
+
128
+ Use this package when:
129
+
130
+ - you are changing an existing server-rendered or static page;
131
+ - the variant needs a few DOM changes or custom browser functions;
132
+ - PostHog already owns assignment and experiment analysis;
133
+ - eligibility, fallback, and repeatable QA matter.
134
+
135
+ Use raw PostHog feature-flag calls when the flag is already part of your application's render logic. Use PostHog's no-code tooling when the change can be authored and maintained there without custom execution logic. This package is deliberately not a visual editor, framework wrapper, or experiment-analysis SDK.
136
+
137
+ ## API
138
+
139
+ ### `runExperiment(featureFlag, options)`
140
+
141
+ Returns `Promise<string | null>`:
142
+
143
+ - the applied variant name when the visitor is eligible;
144
+ - `null` when browser globals are unavailable or `isEligible` returns false.
145
+
146
+ The promise rejects if eligibility or a variant handler throws.
147
+
148
+ | Option | Type | Default | Purpose |
149
+ | --- | --- | --- | --- |
150
+ | `variants` | `Record<string, VariantHandler>` | Required | Complete map of supported variants. Must include the default. |
151
+ | `defaultVariant` | `string` | `control` | Used for unavailable, disabled, empty, timed-out, or unknown flag values. |
152
+ | `isEligible` | `() => boolean \| PromiseLike<boolean>` | `() => true` | Runs before the flag is evaluated. |
153
+ | `posthog` | `PostHogClient` | `window.posthog` | Explicit initialized client for module use. |
154
+ | `featureFlagTimeoutMs` | `number` | `4000` | Maximum wait for PostHog and its first feature-flag notification. |
155
+ | `debug` | `boolean` | `false` | Enables lifecycle and fallback diagnostics. |
156
+
157
+ Boolean flags map `true` to `test` and `false` to the default variant. String-valued flags map directly to the matching configured variant.
158
+
159
+ ### Variant handlers
160
+
161
+ A variant can be one function or an ordered array containing DOM changes and functions:
162
+
163
+ ```ts
164
+ runExperiment('offer-card', {
165
+ variants: {
166
+ control: [],
167
+ test: [
168
+ {
169
+ selector: '.offer-card',
170
+ updates: {
171
+ style: {
172
+ backgroundColor: '#111827',
173
+ color: '#ffffff',
174
+ padding: '24px',
175
+ },
176
+ innerText: 'A focused offer',
177
+ callback(element, variant) {
178
+ element.dataset.experimentVariant = variant
179
+ },
180
+ },
181
+ },
182
+ () => {
183
+ document.documentElement.dataset.hasOfferExperiment = 'true'
184
+ },
185
+ ],
186
+ },
187
+ })
188
+ ```
189
+
190
+ - Every matching element is updated.
191
+ - Style names may be camelCase, kebab-case, or CSS custom properties. Values are stringified and applied with `!important`; include units such as `'24px'` when needed.
192
+ - `innerText` replaces plain text.
193
+ - `innerHTML` is assigned without sanitization. Only use trusted markup.
194
+ - Element callbacks and custom functions run synchronously in array order. Returned promises are not awaited.
195
+
196
+ ## Flicker and loading strategy
197
+
198
+ This package waits for DOM readiness, eligibility, and the PostHog flag before changing the page. A client-rendered visual treatment can therefore flash the original experience.
199
+
200
+ For visible above-the-fold changes:
201
+
202
+ 1. load PostHog and the experiment code as early as your performance budget allows;
203
+ 2. bootstrap or cache PostHog flags when your architecture supports it;
204
+ 3. hide only the specific experiment target with a short, fail-safe timeout when a flash would be worse than a brief concealment;
205
+ 4. prefer server-side or application-rendered variants when flicker-free rendering is essential.
206
+
207
+ The package does not inject a global anti-flicker snippet because a generic page-hiding strategy can damage Core Web Vitals and trap content when scripts fail.
208
+
209
+ ## Current boundaries
210
+
211
+ - Variants apply once. The package does not observe later SPA mutations, revert changes, or clean up effects.
212
+ - Selectors that match nothing are skipped. Use `isEligible` when a missing target means the visitor must not enter the experiment.
213
+ - Handlers are synchronous from the package's perspective; asynchronous work inside them is not awaited.
214
+ - A thrown handler error stops the remaining steps and rejects the returned promise.
215
+ - The package runs in browsers. Importing it during server rendering is safe, but calling it without browser globals resolves to `null`.
216
+
217
+ ## Compatibility and versioning
218
+
219
+ The distributed JavaScript targets modern evergreen browsers and uses standard browser APIs including `Promise`, `URLSearchParams`, and `querySelectorAll`.
220
+
221
+ There is no runtime dependency on `posthog-js`. Compatibility is structural: the client must expose `__loaded`, `onFeatureFlags()`, and `getFeatureFlag()`. CI tests the current release against `posthog-js` 1.433.9 in Chromium.
222
+
223
+ The project follows Semantic Versioning. Before `1.0.0`, breaking public-API changes may ship in a minor release; they will be called out in the changelog. The internal experiment class is not part of the public contract.
224
+
225
+ ## Try the local demo
226
+
227
+ ```bash
228
+ bun install
229
+ bun run demo
230
+ ```
231
+
232
+ Open the URL printed in the terminal. The demo uses the same URL override as production QA, so it works without a PostHog account.
233
+
234
+ More neutral examples are in [`examples/`](./examples).
235
+
236
+ ## Development
237
+
238
+ ```bash
239
+ bun install
240
+ bun run typecheck
241
+ bun run test
242
+ bun run build
243
+ bun run check:package
244
+ ```
245
+
246
+ Unit tests cover the execution contract. Playwright loads the shipped IIFE bundle in Chromium with an actual `posthog-js` client.
247
+
248
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidance and [SECURITY.md](./SECURITY.md) for private vulnerability reporting.
249
+
250
+ ## License
251
+
252
+ [MIT](./LICENSE) © 99Ways
@@ -0,0 +1,35 @@
1
+ export type Awaitable<T> = T | PromiseLike<T>;
2
+ export interface PostHogClient {
3
+ __loaded?: boolean;
4
+ getFeatureFlag(featureFlag: string): boolean | string | null | undefined;
5
+ onFeatureFlags(callback: () => void): (() => void) | void;
6
+ }
7
+ export type StyleUpdates = Record<string, string | number | null | undefined>;
8
+ export interface ElementUpdates {
9
+ style?: StyleUpdates;
10
+ callback?: (element: HTMLElement, variant: string) => unknown;
11
+ innerText?: string;
12
+ innerHTML?: string;
13
+ }
14
+ export interface ElementChange {
15
+ selector: string;
16
+ updates: ElementUpdates;
17
+ }
18
+ export type VariantStep = ElementChange | (() => unknown);
19
+ export type VariantHandler = VariantStep[] | (() => unknown);
20
+ export interface ExperimentOptions {
21
+ variants: Record<string, VariantHandler>;
22
+ defaultVariant?: string;
23
+ isEligible?: () => Awaitable<boolean>;
24
+ posthog?: PostHogClient;
25
+ debug?: boolean;
26
+ featureFlagTimeoutMs?: number;
27
+ }
28
+ export type ExperimentResult = string | null;
29
+ export declare function runExperiment(featureFlag: string, options: ExperimentOptions): Promise<ExperimentResult>;
30
+ declare global {
31
+ interface Window {
32
+ posthog?: PostHogClient;
33
+ runExperiment: typeof runExperiment;
34
+ }
35
+ }
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ class m{featureFlag;variants;defaultVariant;isEligible;explicitPostHog;debug;featureFlagTimeoutMs;runPromise=null;constructor(e,t){this.featureFlag=e;if(!e.trim())throw TypeError("featureFlag must be a non-empty string.");if(this.defaultVariant=t.defaultVariant??"control",this.variants=new Map(Object.entries(t.variants)),this.isEligible=t.isEligible??(()=>!0),this.explicitPostHog=t.posthog,this.debug=t.debug??!1,this.featureFlagTimeoutMs=t.featureFlagTimeoutMs??4000,!Number.isFinite(this.featureFlagTimeoutMs)||this.featureFlagTimeoutMs<0)throw RangeError("featureFlagTimeoutMs must be a finite number greater than or equal to 0.");if(!this.defaultVariant.trim())throw TypeError("defaultVariant must be a non-empty string.");if(!this.variants.has(this.defaultVariant))throw TypeError(`variants must include the default variant '${this.defaultVariant}'.`)}run(){if(!this.runPromise)this.runPromise=this.runOnce();return this.runPromise}async runOnce(){if(typeof window>"u"||typeof document>"u")return this.log("Browser globals are unavailable; skipping the experiment."),null;if(await this.waitForDOMContentLoaded(),this.log("Checking eligibility before evaluating the feature flag."),!await this.isEligible())return this.log("Visitor is ineligible; skipping feature-flag evaluation and variant application."),null;let e=await this.resolveVariant(),t=this.getConfiguredVariantOrDefault(e);return this.log(`Activated variant '${t}'.`),this.applyVariant(t),t}getVariantFromQueryParam(){return new URLSearchParams(window.location.search).get(this.featureFlag)}getPostHog(){let e=this.explicitPostHog??window.posthog;if(e&&e.__loaded===!0&&typeof e.onFeatureFlags==="function"&&typeof e.getFeatureFlag==="function")return e;return}resolveVariant(){let e=this.getVariantFromQueryParam();if(e)return this.log(`Using query-string override variant '${e}'.`),Promise.resolve(e);return this.log("Waiting for initialized PostHog and its first feature-flag notification."),new Promise((t)=>{let i=!1,n,a=!1,r,o,u=()=>{if(!n)return!1;let s=n;n=void 0;try{s()}catch(l){this.warn(`Could not unsubscribe from feature flag '${this.featureFlag}'.`,l)}return!0},d=()=>{if(r!==void 0)clearInterval(r);if(o!==void 0)clearTimeout(o);if(!u())a=!0},g=(s)=>{if(i)return;i=!0,d(),t(s)},p=()=>{if(i)return!0;let s=this.getPostHog();if(!s)return!1;if(r!==void 0)clearInterval(r),r=void 0;try{this.log("PostHog is initialized; subscribing to feature flags.");let l=s.onFeatureFlags(()=>{try{let f=this.getPostHog();if(!f)throw Error("The initialized PostHog instance is no longer available.");let h=f.getFeatureFlag(this.featureFlag),c=this.mapFeatureFlagValue(h);this.log(`PostHog resolved variant '${c}' from raw value ${JSON.stringify(h)}.`),g(c)}catch(f){this.warn(`Could not read feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,f),g(this.defaultVariant)}});if(typeof l==="function")n=l;if(a)a=!1,u();return!0}catch(l){return this.warn(`Could not subscribe to feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,l),g(this.defaultVariant),!0}};if(o=setTimeout(()=>{this.warn(`Feature flag '${this.featureFlag}' was not resolved within ${this.featureFlagTimeoutMs}ms. Applying '${this.defaultVariant}'.`),g(this.defaultVariant)},this.featureFlagTimeoutMs),!p())r=setInterval(p,50)})}mapFeatureFlagValue(e){if(e===!0)return"test";if(e===!1||e===null||e===void 0||e==="")return this.defaultVariant;return e}getConfiguredVariantOrDefault(e){if(this.variants.has(e))return e;return this.warn(`Variant '${e}' is not configured for '${this.featureFlag}'. Applying '${this.defaultVariant}'.`),this.defaultVariant}waitForDOMContentLoaded(){if(document.readyState!=="loading")return this.log("DOM is already ready; skipping the DOMContentLoaded wait."),Promise.resolve();return this.log("Waiting for DOMContentLoaded."),new Promise((e)=>{document.addEventListener("DOMContentLoaded",()=>{this.log("DOMContentLoaded received."),e()},{once:!0})})}applyVariant(e){let t=this.variants.get(e);if(!t)return;this.log(`Applying variant '${e}'.`),this.applyHandler(t,e),this.log(`Finished applying variant '${e}'.`)}applyHandler(e,t){if(typeof e==="function"){e();return}e.forEach((i)=>{if(typeof i==="function"){i();return}this.applyUpdates(i.selector,i.updates,t)})}applyUpdates(e,t,i){let n=document.querySelectorAll(e);if(!n.length){this.warn(`No elements found for selector '${e}'.`);return}this.log(`Selector '${e}' matched ${n.length} element(s).`),n.forEach((a)=>{if(t.style)Object.entries(t.style).forEach(([r,o])=>{if(o===null||o===void 0)return;let u=r.startsWith("--")?r:r.replace(/[A-Z]/g,(d)=>`-${d.toLowerCase()}`);a.style.setProperty(u,String(o),"important")});if(t.innerText!==void 0)a.innerText=t.innerText;if(t.innerHTML!==void 0)a.innerHTML=t.innerHTML;t.callback?.(a,i)})}log(e,...t){if(this.debug)console.log(`[Experiment:${this.featureFlag}] ${e}`,...t)}warn(e,...t){if(this.debug)console.warn(`[Experiment:${this.featureFlag}] ${e}`,...t)}}function y(e,t){return new m(e,t).run()}export{y as runExperiment};
@@ -0,0 +1 @@
1
+ (()=>{class m{featureFlag;variants;defaultVariant;isEligible;explicitPostHog;debug;featureFlagTimeoutMs;runPromise=null;constructor(e,t){this.featureFlag=e;if(!e.trim())throw TypeError("featureFlag must be a non-empty string.");if(this.defaultVariant=t.defaultVariant??"control",this.variants=new Map(Object.entries(t.variants)),this.isEligible=t.isEligible??(()=>!0),this.explicitPostHog=t.posthog,this.debug=t.debug??!1,this.featureFlagTimeoutMs=t.featureFlagTimeoutMs??4000,!Number.isFinite(this.featureFlagTimeoutMs)||this.featureFlagTimeoutMs<0)throw RangeError("featureFlagTimeoutMs must be a finite number greater than or equal to 0.");if(!this.defaultVariant.trim())throw TypeError("defaultVariant must be a non-empty string.");if(!this.variants.has(this.defaultVariant))throw TypeError(`variants must include the default variant '${this.defaultVariant}'.`)}run(){if(!this.runPromise)this.runPromise=this.runOnce();return this.runPromise}async runOnce(){if(typeof window>"u"||typeof document>"u")return this.log("Browser globals are unavailable; skipping the experiment."),null;if(await this.waitForDOMContentLoaded(),this.log("Checking eligibility before evaluating the feature flag."),!await this.isEligible())return this.log("Visitor is ineligible; skipping feature-flag evaluation and variant application."),null;let e=await this.resolveVariant(),t=this.getConfiguredVariantOrDefault(e);return this.log(`Activated variant '${t}'.`),this.applyVariant(t),t}getVariantFromQueryParam(){return new URLSearchParams(window.location.search).get(this.featureFlag)}getPostHog(){let e=this.explicitPostHog??window.posthog;if(e&&e.__loaded===!0&&typeof e.onFeatureFlags==="function"&&typeof e.getFeatureFlag==="function")return e;return}resolveVariant(){let e=this.getVariantFromQueryParam();if(e)return this.log(`Using query-string override variant '${e}'.`),Promise.resolve(e);return this.log("Waiting for initialized PostHog and its first feature-flag notification."),new Promise((t)=>{let i=!1,n,a=!1,r,o,u=()=>{if(!n)return!1;let s=n;n=void 0;try{s()}catch(l){this.warn(`Could not unsubscribe from feature flag '${this.featureFlag}'.`,l)}return!0},d=()=>{if(r!==void 0)clearInterval(r);if(o!==void 0)clearTimeout(o);if(!u())a=!0},g=(s)=>{if(i)return;i=!0,d(),t(s)},p=()=>{if(i)return!0;let s=this.getPostHog();if(!s)return!1;if(r!==void 0)clearInterval(r),r=void 0;try{this.log("PostHog is initialized; subscribing to feature flags.");let l=s.onFeatureFlags(()=>{try{let f=this.getPostHog();if(!f)throw Error("The initialized PostHog instance is no longer available.");let h=f.getFeatureFlag(this.featureFlag),c=this.mapFeatureFlagValue(h);this.log(`PostHog resolved variant '${c}' from raw value ${JSON.stringify(h)}.`),g(c)}catch(f){this.warn(`Could not read feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,f),g(this.defaultVariant)}});if(typeof l==="function")n=l;if(a)a=!1,u();return!0}catch(l){return this.warn(`Could not subscribe to feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,l),g(this.defaultVariant),!0}};if(o=setTimeout(()=>{this.warn(`Feature flag '${this.featureFlag}' was not resolved within ${this.featureFlagTimeoutMs}ms. Applying '${this.defaultVariant}'.`),g(this.defaultVariant)},this.featureFlagTimeoutMs),!p())r=setInterval(p,50)})}mapFeatureFlagValue(e){if(e===!0)return"test";if(e===!1||e===null||e===void 0||e==="")return this.defaultVariant;return e}getConfiguredVariantOrDefault(e){if(this.variants.has(e))return e;return this.warn(`Variant '${e}' is not configured for '${this.featureFlag}'. Applying '${this.defaultVariant}'.`),this.defaultVariant}waitForDOMContentLoaded(){if(document.readyState!=="loading")return this.log("DOM is already ready; skipping the DOMContentLoaded wait."),Promise.resolve();return this.log("Waiting for DOMContentLoaded."),new Promise((e)=>{document.addEventListener("DOMContentLoaded",()=>{this.log("DOMContentLoaded received."),e()},{once:!0})})}applyVariant(e){let t=this.variants.get(e);if(!t)return;this.log(`Applying variant '${e}'.`),this.applyHandler(t,e),this.log(`Finished applying variant '${e}'.`)}applyHandler(e,t){if(typeof e==="function"){e();return}e.forEach((i)=>{if(typeof i==="function"){i();return}this.applyUpdates(i.selector,i.updates,t)})}applyUpdates(e,t,i){let n=document.querySelectorAll(e);if(!n.length){this.warn(`No elements found for selector '${e}'.`);return}this.log(`Selector '${e}' matched ${n.length} element(s).`),n.forEach((a)=>{if(t.style)Object.entries(t.style).forEach(([r,o])=>{if(o===null||o===void 0)return;let u=r.startsWith("--")?r:r.replace(/[A-Z]/g,(d)=>`-${d.toLowerCase()}`);a.style.setProperty(u,String(o),"important")});if(t.innerText!==void 0)a.innerText=t.innerText;if(t.innerHTML!==void 0)a.innerHTML=t.innerHTML;t.callback?.(a,i)})}log(e,...t){if(this.debug)console.log(`[Experiment:${this.featureFlag}] ${e}`,...t)}warn(e,...t){if(this.debug)console.warn(`[Experiment:${this.featureFlag}] ${e}`,...t)}}function y(e,t){return new m(e,t).run()}if(typeof window<"u")window.runExperiment=y;})();
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@99ways/posthog-page-experiments",
3
+ "version": "0.1.0",
4
+ "description": "Code-first page experiments for PostHog with eligibility checks, URL QA overrides, and safe fallbacks.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "99Ways",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/99ways-io/posthog-page-experiments.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/99ways-io/posthog-page-experiments/issues"
14
+ },
15
+ "homepage": "https://github.com/99ways-io/posthog-page-experiments#readme",
16
+ "keywords": [
17
+ "posthog",
18
+ "ab-testing",
19
+ "experimentation",
20
+ "feature-flags",
21
+ "cro",
22
+ "growth-engineering",
23
+ "javascript",
24
+ "typescript"
25
+ ],
26
+ "files": [
27
+ "dist",
28
+ "CHANGELOG.md",
29
+ "LICENSE",
30
+ "README.md"
31
+ ],
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
+ },
37
+ "./browser": "./dist/posthog-page-experiments.min.js"
38
+ },
39
+ "main": "./dist/index.js",
40
+ "module": "./dist/index.js",
41
+ "types": "./dist/index.d.ts",
42
+ "unpkg": "./dist/posthog-page-experiments.min.js",
43
+ "jsdelivr": "./dist/posthog-page-experiments.min.js",
44
+ "sideEffects": [
45
+ "./dist/posthog-page-experiments.min.js"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public",
49
+ "provenance": true
50
+ },
51
+ "packageManager": "bun@1.4.2",
52
+ "scripts": {
53
+ "clean": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
54
+ "build": "bun run clean && bun run build:module && bun run build:browser && bun run build:types",
55
+ "build:module": "bun build ./src/index.ts --outfile ./dist/index.js --format esm --target browser --minify",
56
+ "build:browser": "bun build ./src/browser.ts --outfile ./dist/posthog-page-experiments.min.js --format iife --target browser --minify",
57
+ "build:types": "tsc -p tsconfig.build.json",
58
+ "build:test-fixture": "bun build ./tests/fixtures/posthog.ts --outfile ./tests/fixtures/posthog.bundle.js --format iife --target browser",
59
+ "typecheck": "tsc --noEmit",
60
+ "test": "bun run test:unit && bun run test:browser",
61
+ "test:unit": "bun test ./src/index.test.ts",
62
+ "test:browser": "bun run build && bun run build:test-fixture && playwright test",
63
+ "check:package": "npm pack --dry-run && publint",
64
+ "demo": "bun run build && bun ./scripts/serve-demo.ts"
65
+ },
66
+ "devDependencies": {
67
+ "@playwright/test": "^1.63.0",
68
+ "@types/bun": "^1.4.2",
69
+ "posthog-js": "^1.433.9",
70
+ "publint": "^0.3.24",
71
+ "typescript": "^7.0.2"
72
+ }
73
+ }