@webiny/website-builder-sdk 6.4.11 → 6.6.0-alpha.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 (88) hide show
  1. package/ContentSdk.d.ts +13 -0
  2. package/ContentSdk.js +25 -0
  3. package/ContentSdk.js.map +1 -1
  4. package/ElementFactory.js +3 -1
  5. package/ElementFactory.js.map +1 -1
  6. package/asset/deliveryUrl.d.ts +41 -0
  7. package/asset/deliveryUrl.js +34 -0
  8. package/asset/deliveryUrl.js.map +1 -0
  9. package/asset/deliveryUrl.test.d.ts +1 -0
  10. package/asset/deliveryUrl.test.js +127 -0
  11. package/asset/deliveryUrl.test.js.map +1 -0
  12. package/asset/geometry.d.ts +7 -0
  13. package/asset/geometry.js +10 -0
  14. package/asset/geometry.js.map +1 -0
  15. package/asset/imageSrcSet.d.ts +63 -0
  16. package/asset/imageSrcSet.js +98 -0
  17. package/asset/imageSrcSet.js.map +1 -0
  18. package/asset/imageSrcSet.test.d.ts +1 -0
  19. package/asset/imageSrcSet.test.js +103 -0
  20. package/asset/imageSrcSet.test.js.map +1 -0
  21. package/asset/index.d.ts +5 -0
  22. package/asset/index.js +5 -0
  23. package/asset/normalize.d.ts +24 -0
  24. package/asset/normalize.js +102 -0
  25. package/asset/normalize.js.map +1 -0
  26. package/asset/normalize.test.d.ts +1 -0
  27. package/asset/normalize.test.js +256 -0
  28. package/asset/normalize.test.js.map +1 -0
  29. package/asset/types.d.ts +2 -0
  30. package/asset/types.js +0 -0
  31. package/createComponent.d.ts +5 -0
  32. package/createComponent.js +32 -0
  33. package/createComponent.js.map +1 -0
  34. package/dataProviders/ApiClient.d.ts +3 -1
  35. package/dataProviders/ApiClient.js +4 -2
  36. package/dataProviders/ApiClient.js.map +1 -1
  37. package/dataProviders/DefaultDataProvider.d.ts +4 -0
  38. package/dataProviders/DefaultDataProvider.js +35 -0
  39. package/dataProviders/DefaultDataProvider.js.map +1 -1
  40. package/dataProviders/GET_EXPERIMENT_PAUSED.d.ts +1 -0
  41. package/dataProviders/GET_EXPERIMENT_PAUSED.js +17 -0
  42. package/dataProviders/GET_EXPERIMENT_PAUSED.js.map +1 -0
  43. package/dataProviders/GET_PAGE_EXPERIMENT.d.ts +1 -0
  44. package/dataProviders/GET_PAGE_EXPERIMENT.js +32 -0
  45. package/dataProviders/GET_PAGE_EXPERIMENT.js.map +1 -0
  46. package/dataProviders/GET_VARIANT_CONTENT.d.ts +1 -0
  47. package/dataProviders/GET_VARIANT_CONTENT.js +24 -0
  48. package/dataProviders/GET_VARIANT_CONTENT.js.map +1 -0
  49. package/dataProviders/NullDataProvider.d.ts +4 -0
  50. package/dataProviders/NullDataProvider.js +9 -0
  51. package/dataProviders/NullDataProvider.js.map +1 -1
  52. package/experiments/PostHogAnalyticsProvider.d.ts +31 -0
  53. package/experiments/PostHogAnalyticsProvider.js +28 -0
  54. package/experiments/PostHogAnalyticsProvider.js.map +1 -0
  55. package/experiments/analyticsProvider.d.ts +4 -0
  56. package/experiments/analyticsProvider.js +11 -0
  57. package/experiments/analyticsProvider.js.map +1 -0
  58. package/experiments/bucketing.d.ts +27 -0
  59. package/experiments/bucketing.js +91 -0
  60. package/experiments/bucketing.js.map +1 -0
  61. package/experiments/bucketing.test.d.ts +1 -0
  62. package/experiments/bucketing.test.js +183 -0
  63. package/experiments/bucketing.test.js.map +1 -0
  64. package/experiments/index.d.ts +5 -0
  65. package/experiments/index.js +5 -0
  66. package/experiments/render.d.ts +79 -0
  67. package/experiments/render.js +149 -0
  68. package/experiments/render.js.map +1 -0
  69. package/experiments/types.d.ts +108 -0
  70. package/experiments/types.js +4 -0
  71. package/experiments/types.js.map +1 -0
  72. package/image/geometry.d.ts +54 -0
  73. package/image/geometry.js +112 -0
  74. package/image/geometry.js.map +1 -0
  75. package/image/geometry.test.d.ts +1 -0
  76. package/image/geometry.test.js +190 -0
  77. package/image/geometry.test.js.map +1 -0
  78. package/image/index.d.ts +3 -0
  79. package/image/index.js +3 -0
  80. package/image/presets.d.ts +6 -0
  81. package/image/presets.js +20 -0
  82. package/image/presets.js.map +1 -0
  83. package/image/types.d.ts +10 -0
  84. package/image/types.js +0 -0
  85. package/index.d.ts +4 -0
  86. package/index.js +4 -0
  87. package/package.json +7 -6
  88. package/types.d.ts +5 -0
@@ -0,0 +1,183 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { assignVariant, forcedAssignment, hashToUnit, matchesTargeting } from "./bucketing.js";
3
+ import { CONTROL_VARIANT_ID } from "./types.js";
4
+ const experiment = (overrides = {})=>({
5
+ experimentId: "exp-1",
6
+ revisionId: "page-1#0001",
7
+ pageEntryId: "page-1",
8
+ path: "/pricing",
9
+ status: "running",
10
+ tenantId: "root",
11
+ controlVariantId: CONTROL_VARIANT_ID,
12
+ trafficSplit: {
13
+ control: 50,
14
+ variants: {
15
+ "variant-a": 50
16
+ }
17
+ },
18
+ targeting: {
19
+ trafficPercentage: 100
20
+ },
21
+ analytics: {
22
+ provider: "posthog"
23
+ },
24
+ variants: [
25
+ {
26
+ variantId: "variant-a",
27
+ name: "Variant A"
28
+ }
29
+ ],
30
+ ...overrides
31
+ });
32
+ const visitor = (overrides = {})=>({
33
+ visitorId: "visitor-1",
34
+ ...overrides
35
+ });
36
+ describe("hashToUnit", ()=>{
37
+ it("is deterministic and within [0, 1)", ()=>{
38
+ const a = hashToUnit("visitor-1:exp-1");
39
+ const b = hashToUnit("visitor-1:exp-1");
40
+ expect(a).toBe(b);
41
+ expect(a).toBeGreaterThanOrEqual(0);
42
+ expect(a).toBeLessThan(1);
43
+ });
44
+ it("differs across seeds", ()=>{
45
+ expect(hashToUnit("visitor-1:exp-1")).not.toBe(hashToUnit("visitor-2:exp-1"));
46
+ });
47
+ });
48
+ describe("assignVariant", ()=>{
49
+ it("assigns the same visitor to the same bucket every time", ()=>{
50
+ const first = assignVariant(experiment(), visitor());
51
+ const second = assignVariant(experiment(), visitor());
52
+ expect(second).toEqual(first);
53
+ });
54
+ it("buckets the same visitor differently across experiments", ()=>{
55
+ const a = assignVariant(experiment({
56
+ experimentId: "exp-1"
57
+ }), visitor());
58
+ const b = assignVariant(experiment({
59
+ experimentId: "exp-2"
60
+ }), visitor());
61
+ expect(hashToUnit("visitor-1:exp-1")).not.toBe(hashToUnit("visitor-1:exp-2"));
62
+ expect([
63
+ a.variantId,
64
+ b.variantId
65
+ ].every(Boolean)).toBe(true);
66
+ });
67
+ it("respects the traffic split distribution", ()=>{
68
+ const exp = experiment({
69
+ trafficSplit: {
70
+ control: 80,
71
+ variants: {
72
+ "variant-a": 20
73
+ }
74
+ }
75
+ });
76
+ let control = 0;
77
+ let variant = 0;
78
+ for(let i = 0; i < 5000; i++){
79
+ const assignment = assignVariant(exp, visitor({
80
+ visitorId: `v-${i}`
81
+ }));
82
+ if (assignment.isControl) control++;
83
+ else variant++;
84
+ }
85
+ const variantShare = variant / (control + variant);
86
+ expect(variantShare).toBeGreaterThan(0.15);
87
+ expect(variantShare).toBeLessThan(0.25);
88
+ });
89
+ it("excludes visitors outside the traffic percentage and serves control", ()=>{
90
+ const exp = experiment({
91
+ targeting: {
92
+ trafficPercentage: 0
93
+ }
94
+ });
95
+ const assignment = assignVariant(exp, visitor());
96
+ expect(assignment.isControl).toBe(true);
97
+ expect(assignment.excluded).toBe(true);
98
+ });
99
+ it("excludes visitors who do not match geo targeting", ()=>{
100
+ const exp = experiment({
101
+ targeting: {
102
+ trafficPercentage: 100,
103
+ geo: [
104
+ "GB"
105
+ ]
106
+ }
107
+ });
108
+ const assignment = assignVariant(exp, visitor({
109
+ country: "US"
110
+ }));
111
+ expect(assignment.excluded).toBe(true);
112
+ expect(assignment.isControl).toBe(true);
113
+ });
114
+ it("ignores variants that are not ready", ()=>{
115
+ const exp = experiment({
116
+ trafficSplit: {
117
+ control: 0,
118
+ variants: {
119
+ "variant-a": 50,
120
+ "variant-ghost": 50
121
+ }
122
+ },
123
+ variants: [
124
+ {
125
+ variantId: "variant-a",
126
+ name: "Variant A"
127
+ }
128
+ ]
129
+ });
130
+ for(let i = 0; i < 200; i++){
131
+ const assignment = assignVariant(exp, visitor({
132
+ visitorId: `v-${i}`
133
+ }));
134
+ expect(assignment.variantId).not.toBe("variant-ghost");
135
+ }
136
+ });
137
+ });
138
+ describe("matchesTargeting", ()=>{
139
+ it("matches when no geo/device restriction is set", ()=>{
140
+ expect(matchesTargeting({
141
+ trafficPercentage: 100
142
+ }, visitor())).toBe(true);
143
+ });
144
+ it("matches device targeting", ()=>{
145
+ expect(matchesTargeting({
146
+ trafficPercentage: 100,
147
+ device: [
148
+ "mobile"
149
+ ]
150
+ }, visitor({
151
+ device: "mobile"
152
+ }))).toBe(true);
153
+ expect(matchesTargeting({
154
+ trafficPercentage: 100,
155
+ device: [
156
+ "mobile"
157
+ ]
158
+ }, visitor({
159
+ device: "desktop"
160
+ }))).toBe(false);
161
+ });
162
+ });
163
+ describe("forcedAssignment", ()=>{
164
+ it("forces a ready variant and marks it as not counted", ()=>{
165
+ const assignment = forcedAssignment(experiment(), "variant-a");
166
+ expect(assignment).toEqual({
167
+ variantId: "variant-a",
168
+ isControl: false,
169
+ excluded: false,
170
+ forced: true
171
+ });
172
+ });
173
+ it("forces the control bucket", ()=>{
174
+ const assignment = forcedAssignment(experiment(), CONTROL_VARIANT_ID);
175
+ expect(assignment?.isControl).toBe(true);
176
+ expect(assignment?.forced).toBe(true);
177
+ });
178
+ it("returns null for an unknown variant", ()=>{
179
+ expect(forcedAssignment(experiment(), "nope")).toBeNull();
180
+ });
181
+ });
182
+
183
+ //# sourceMappingURL=bucketing.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"experiments/bucketing.test.js","sources":["../../src/experiments/bucketing.test.ts"],"sourcesContent":["import { describe, it, expect } from \"vitest\";\nimport { assignVariant, forcedAssignment, hashToUnit, matchesTargeting } from \"./bucketing.js\";\nimport { CONTROL_VARIANT_ID, type ActiveExperiment, type VisitorContext } from \"./types.js\";\n\nconst experiment = (overrides: Partial<ActiveExperiment> = {}): ActiveExperiment => ({\n experimentId: \"exp-1\",\n revisionId: \"page-1#0001\",\n pageEntryId: \"page-1\",\n path: \"/pricing\",\n status: \"running\",\n tenantId: \"root\",\n controlVariantId: CONTROL_VARIANT_ID,\n trafficSplit: { control: 50, variants: { \"variant-a\": 50 } },\n targeting: { trafficPercentage: 100 },\n analytics: { provider: \"posthog\" },\n variants: [{ variantId: \"variant-a\", name: \"Variant A\" }],\n ...overrides\n});\n\nconst visitor = (overrides: Partial<VisitorContext> = {}): VisitorContext => ({\n visitorId: \"visitor-1\",\n ...overrides\n});\n\ndescribe(\"hashToUnit\", () => {\n it(\"is deterministic and within [0, 1)\", () => {\n const a = hashToUnit(\"visitor-1:exp-1\");\n const b = hashToUnit(\"visitor-1:exp-1\");\n expect(a).toBe(b);\n expect(a).toBeGreaterThanOrEqual(0);\n expect(a).toBeLessThan(1);\n });\n\n it(\"differs across seeds\", () => {\n expect(hashToUnit(\"visitor-1:exp-1\")).not.toBe(hashToUnit(\"visitor-2:exp-1\"));\n });\n});\n\ndescribe(\"assignVariant\", () => {\n it(\"assigns the same visitor to the same bucket every time\", () => {\n const first = assignVariant(experiment(), visitor());\n const second = assignVariant(experiment(), visitor());\n expect(second).toEqual(first);\n });\n\n it(\"buckets the same visitor differently across experiments\", () => {\n const a = assignVariant(experiment({ experimentId: \"exp-1\" }), visitor());\n const b = assignVariant(experiment({ experimentId: \"exp-2\" }), visitor());\n // Not guaranteed to differ, but the seed differs; assert the seeds are independent.\n expect(hashToUnit(\"visitor-1:exp-1\")).not.toBe(hashToUnit(\"visitor-1:exp-2\"));\n expect([a.variantId, b.variantId].every(Boolean)).toBe(true);\n });\n\n it(\"respects the traffic split distribution\", () => {\n const exp = experiment({\n trafficSplit: { control: 80, variants: { \"variant-a\": 20 } }\n });\n let control = 0;\n let variant = 0;\n for (let i = 0; i < 5000; i++) {\n const assignment = assignVariant(exp, visitor({ visitorId: `v-${i}` }));\n if (assignment.isControl) {\n control++;\n } else {\n variant++;\n }\n }\n const variantShare = variant / (control + variant);\n // Expect ~20% in the variant, allow a generous tolerance.\n expect(variantShare).toBeGreaterThan(0.15);\n expect(variantShare).toBeLessThan(0.25);\n });\n\n it(\"excludes visitors outside the traffic percentage and serves control\", () => {\n const exp = experiment({ targeting: { trafficPercentage: 0 } });\n const assignment = assignVariant(exp, visitor());\n expect(assignment.isControl).toBe(true);\n expect(assignment.excluded).toBe(true);\n });\n\n it(\"excludes visitors who do not match geo targeting\", () => {\n const exp = experiment({ targeting: { trafficPercentage: 100, geo: [\"GB\"] } });\n const assignment = assignVariant(exp, visitor({ country: \"US\" }));\n expect(assignment.excluded).toBe(true);\n expect(assignment.isControl).toBe(true);\n });\n\n it(\"ignores variants that are not ready\", () => {\n const exp = experiment({\n trafficSplit: { control: 0, variants: { \"variant-a\": 50, \"variant-ghost\": 50 } },\n variants: [{ variantId: \"variant-a\", name: \"Variant A\" }]\n });\n for (let i = 0; i < 200; i++) {\n const assignment = assignVariant(exp, visitor({ visitorId: `v-${i}` }));\n expect(assignment.variantId).not.toBe(\"variant-ghost\");\n }\n });\n});\n\ndescribe(\"matchesTargeting\", () => {\n it(\"matches when no geo/device restriction is set\", () => {\n expect(matchesTargeting({ trafficPercentage: 100 }, visitor())).toBe(true);\n });\n\n it(\"matches device targeting\", () => {\n expect(\n matchesTargeting(\n { trafficPercentage: 100, device: [\"mobile\"] },\n visitor({ device: \"mobile\" })\n )\n ).toBe(true);\n expect(\n matchesTargeting(\n { trafficPercentage: 100, device: [\"mobile\"] },\n visitor({ device: \"desktop\" })\n )\n ).toBe(false);\n });\n});\n\ndescribe(\"forcedAssignment\", () => {\n it(\"forces a ready variant and marks it as not counted\", () => {\n const assignment = forcedAssignment(experiment(), \"variant-a\");\n expect(assignment).toEqual({\n variantId: \"variant-a\",\n isControl: false,\n excluded: false,\n forced: true\n });\n });\n\n it(\"forces the control bucket\", () => {\n const assignment = forcedAssignment(experiment(), CONTROL_VARIANT_ID);\n expect(assignment?.isControl).toBe(true);\n expect(assignment?.forced).toBe(true);\n });\n\n it(\"returns null for an unknown variant\", () => {\n expect(forcedAssignment(experiment(), \"nope\")).toBeNull();\n });\n});\n"],"names":["experiment","overrides","CONTROL_VARIANT_ID","visitor","describe","it","a","hashToUnit","b","expect","first","assignVariant","second","Boolean","exp","control","variant","i","assignment","variantShare","matchesTargeting","forcedAssignment"],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./bucketing.js";
3
+ export * from "./analyticsProvider.js";
4
+ export * from "./PostHogAnalyticsProvider.js";
5
+ export * from "./render.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./bucketing.js";
3
+ export * from "./analyticsProvider.js";
4
+ export * from "./PostHogAnalyticsProvider.js";
5
+ export * from "./render.js";
@@ -0,0 +1,79 @@
1
+ import type { PublicPage } from "../types.js";
2
+ import type { ActiveExperiment, DeviceType, VariantAssignment, VariantContent, VisitorContext } from "./types.js";
3
+ /** The cookie that persists the stable visitor id used for deterministic bucketing. */
4
+ export declare const DEFAULT_VISITOR_COOKIE = "wb_ab_vid";
5
+ /** Default query parameter used to force a variant for QA/review. */
6
+ export declare const FORCED_VARIANT_PARAM = "wb-variant";
7
+ /** The minimal data surface the render helper needs. ContentSdk satisfies this. */
8
+ export interface ExperimentSdk {
9
+ getPage(path: string): Promise<PublicPage | null>;
10
+ getPageExperiment(path: string): Promise<ActiveExperiment | null>;
11
+ getVariantContent(variantId: string): Promise<VariantContent | null>;
12
+ /** Runtime kill-switch check. When omitted, the pause state is not consulted. */
13
+ getExperimentPaused?(experimentId: string): Promise<boolean>;
14
+ }
15
+ export interface ExperimentCookie {
16
+ name: string;
17
+ value: string;
18
+ maxAge: number;
19
+ }
20
+ export interface GetPageWithExperimentOptions {
21
+ /** Forced variant id (from the `?wb-variant=` query parameter). Overrides bucketing, never counted. */
22
+ forcedVariantId?: string;
23
+ /**
24
+ * Request query parameters. When provided, the forced variant is read from
25
+ * `searchParams[FORCED_VARIANT_PARAM]` unless `forcedVariantId` is set explicitly. Convenient
26
+ * for framework handlers that already have the parsed query (e.g. Next.js `searchParams`).
27
+ */
28
+ searchParams?: Record<string, string | string[] | undefined>;
29
+ /** Explicit visitor id. When omitted, it is read from the cookie or generated. */
30
+ visitorId?: string;
31
+ /** Explicit ISO country code. When omitted, it is read from common CDN geo headers. */
32
+ country?: string;
33
+ /** Explicit device type. When omitted, it is derived from the user agent. */
34
+ device?: DeviceType;
35
+ /** Header names to read the visitor country from, in priority order. */
36
+ geoHeaders?: string[];
37
+ /** Bucketing cookie name. Defaults to `wb_ab_vid`. */
38
+ cookieName?: string;
39
+ /** Bucketing cookie max age in seconds. Defaults to one year. */
40
+ cookieMaxAge?: number;
41
+ /** Set false to skip exposure emission (e.g. when consent has not been granted). */
42
+ emitExposure?: boolean;
43
+ /** Visitor id generator. Defaults to nanoid. */
44
+ generateVisitorId?: () => string;
45
+ }
46
+ export interface ExperimentRenderResult {
47
+ /** The content to render server-side: the baseline (control) or a variant overlay. */
48
+ page: PublicPage | null;
49
+ /** The active experiment, or null when there is none for the path. */
50
+ experiment: ActiveExperiment | null;
51
+ /** The visitor's assignment, or null when no experiment applies. */
52
+ assignment: VariantAssignment | null;
53
+ /**
54
+ * Set when the caller should persist the bucketing cookie (i.e. a visitor id was generated
55
+ * this request). Persisting it in a Route Handler / middleware keeps bucketing stable.
56
+ */
57
+ cookie?: ExperimentCookie;
58
+ }
59
+ /**
60
+ * Resolve request-time visitor context from request headers/cookies. Never uses a logged-in
61
+ * identity. Returns whether a visitor id was generated, so the caller can persist the cookie.
62
+ */
63
+ export declare const resolveVisitorContext: (options?: GetPageWithExperimentOptions) => Promise<{
64
+ context: VisitorContext;
65
+ generatedVisitorId: boolean;
66
+ }>;
67
+ /**
68
+ * Resolve and render the right page for a visitor, server-side, with no client-side content
69
+ * swap.
70
+ *
71
+ * Caching contract (important — handled here so projects do not reimplement it):
72
+ * - The baseline (control) is fetched via `getPage(path)` and caches per path.
73
+ * - The active experiment metadata is fetched via `getPageExperiment(path)` and caches per path.
74
+ * - A variant's content is fetched via `getVariantContent(variantId)` and caches per variant id.
75
+ * Every request therefore resolves to one of a small, fixed set of cacheable objects keyed by
76
+ * (path) or (variant id) — NEVER by the raw visitor id. Each active variant adds exactly one
77
+ * cacheable object per URL.
78
+ */
79
+ export declare const getPageWithExperiment: (sdk: ExperimentSdk, path: string, options?: GetPageWithExperimentOptions) => Promise<ExperimentRenderResult>;
@@ -0,0 +1,149 @@
1
+ import { nanoid } from "nanoid";
2
+ import { getHeadersProvider } from "../headersProvider.js";
3
+ import { assignVariant, forcedAssignment } from "./bucketing.js";
4
+ import { getAnalyticsProvider } from "./analyticsProvider.js";
5
+ const DEFAULT_VISITOR_COOKIE = "wb_ab_vid";
6
+ const FORCED_VARIANT_PARAM = "wb-variant";
7
+ const DEFAULT_GEO_HEADERS = [
8
+ "x-vercel-ip-country",
9
+ "cloudfront-viewer-country",
10
+ "cf-ipcountry",
11
+ "x-country-code"
12
+ ];
13
+ const readCookie = (cookieHeader, name)=>{
14
+ if (!cookieHeader) return;
15
+ for (const part of cookieHeader.split(";")){
16
+ const index = part.indexOf("=");
17
+ if (-1 === index) continue;
18
+ const key = part.slice(0, index).trim();
19
+ if (key === name) return decodeURIComponent(part.slice(index + 1).trim());
20
+ }
21
+ };
22
+ const detectDevice = (userAgent)=>{
23
+ if (!userAgent) return;
24
+ if (/iPad|Tablet|PlayBook|Silk/i.test(userAgent)) return "tablet";
25
+ if (/Mobi|Android.+Mobile|iPhone|iPod|Windows Phone/i.test(userAgent)) return "mobile";
26
+ return "desktop";
27
+ };
28
+ const resolveVisitorContext = async (options = {})=>{
29
+ const cookieName = options.cookieName ?? DEFAULT_VISITOR_COOKIE;
30
+ const generate = options.generateVisitorId ?? (()=>nanoid());
31
+ let headers;
32
+ const provider = getHeadersProvider();
33
+ if (provider) try {
34
+ headers = await provider();
35
+ } catch {
36
+ headers = void 0;
37
+ }
38
+ let visitorId = options.visitorId;
39
+ let generatedVisitorId = false;
40
+ if (!visitorId) visitorId = readCookie(headers?.get("cookie"), cookieName);
41
+ if (!visitorId) {
42
+ visitorId = generate();
43
+ generatedVisitorId = true;
44
+ }
45
+ let country = options.country;
46
+ if (!country && headers) for (const header of options.geoHeaders ?? DEFAULT_GEO_HEADERS){
47
+ const value = headers.get(header);
48
+ if (value) {
49
+ country = value.toUpperCase();
50
+ break;
51
+ }
52
+ }
53
+ const device = options.device ?? detectDevice(headers?.get("user-agent"));
54
+ return {
55
+ context: {
56
+ visitorId,
57
+ country,
58
+ device
59
+ },
60
+ generatedVisitorId
61
+ };
62
+ };
63
+ const resolveForcedVariantId = (options)=>{
64
+ if (options.forcedVariantId) return options.forcedVariantId;
65
+ const raw = options.searchParams?.[FORCED_VARIANT_PARAM];
66
+ return Array.isArray(raw) ? raw[0] : raw;
67
+ };
68
+ const overlayVariant = (baseline, variant)=>({
69
+ ...baseline,
70
+ properties: variant.properties,
71
+ bindings: variant.bindings,
72
+ elements: variant.elements,
73
+ extensions: variant.extensions,
74
+ metadata: variant.metadata
75
+ });
76
+ const getPageWithExperiment = async (sdk, path, options = {})=>{
77
+ const [controlPage, experiment] = await Promise.all([
78
+ sdk.getPage(path),
79
+ sdk.getPageExperiment(path)
80
+ ]);
81
+ if (!experiment || !controlPage) return {
82
+ page: controlPage,
83
+ experiment: experiment ?? null,
84
+ assignment: null
85
+ };
86
+ if ("running" !== experiment.status) return {
87
+ page: controlPage,
88
+ experiment,
89
+ assignment: null
90
+ };
91
+ if (sdk.getExperimentPaused) {
92
+ const paused = await sdk.getExperimentPaused(experiment.experimentId).catch(()=>false);
93
+ if (paused) return {
94
+ page: controlPage,
95
+ experiment,
96
+ assignment: null
97
+ };
98
+ }
99
+ const forcedVariantId = resolveForcedVariantId(options);
100
+ const { context, generatedVisitorId } = await resolveVisitorContext(options);
101
+ let assignment = null;
102
+ if (forcedVariantId) assignment = forcedAssignment(experiment, forcedVariantId);
103
+ if (!assignment) assignment = assignVariant(experiment, context);
104
+ let page = controlPage;
105
+ if (!assignment.isControl) {
106
+ const variant = await sdk.getVariantContent(assignment.variantId);
107
+ if (variant) page = overlayVariant(controlPage, variant);
108
+ else assignment = {
109
+ variantId: experiment.controlVariantId,
110
+ isControl: true,
111
+ excluded: assignment.excluded,
112
+ forced: assignment.forced
113
+ };
114
+ }
115
+ const shouldEmit = (options.emitExposure ?? true) && !assignment.excluded && !assignment.forced;
116
+ if (shouldEmit) {
117
+ const provider = getAnalyticsProvider(experiment.analytics.provider);
118
+ if (provider) {
119
+ const experimentKey = "string" == typeof experiment.analytics.experimentKey ? experiment.analytics.experimentKey : void 0;
120
+ try {
121
+ await provider.emitExposure({
122
+ experimentId: experiment.experimentId,
123
+ experimentKey,
124
+ variantId: assignment.variantId,
125
+ revisionId: experiment.revisionId,
126
+ tenantId: experiment.tenantId,
127
+ visitorId: context.visitorId,
128
+ pageId: experiment.pageEntryId,
129
+ path: experiment.path,
130
+ timestamp: new Date().toISOString()
131
+ });
132
+ } catch {}
133
+ }
134
+ }
135
+ const result = {
136
+ page,
137
+ experiment,
138
+ assignment
139
+ };
140
+ if (generatedVisitorId) result.cookie = {
141
+ name: options.cookieName ?? DEFAULT_VISITOR_COOKIE,
142
+ value: context.visitorId,
143
+ maxAge: options.cookieMaxAge ?? 31536000
144
+ };
145
+ return result;
146
+ };
147
+ export { DEFAULT_VISITOR_COOKIE, FORCED_VARIANT_PARAM, getPageWithExperiment, resolveVisitorContext };
148
+
149
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"experiments/render.js","sources":["../../src/experiments/render.ts"],"sourcesContent":["import { nanoid } from \"nanoid\";\nimport type { PublicPage } from \"~/types.js\";\nimport { getHeadersProvider } from \"~/headersProvider.js\";\nimport { assignVariant, forcedAssignment } from \"./bucketing.js\";\nimport { getAnalyticsProvider } from \"./analyticsProvider.js\";\nimport type {\n ActiveExperiment,\n DeviceType,\n VariantAssignment,\n VariantContent,\n VisitorContext\n} from \"./types.js\";\n\n/** The cookie that persists the stable visitor id used for deterministic bucketing. */\nexport const DEFAULT_VISITOR_COOKIE = \"wb_ab_vid\";\n\n/** Default query parameter used to force a variant for QA/review. */\nexport const FORCED_VARIANT_PARAM = \"wb-variant\";\n\nconst DEFAULT_GEO_HEADERS = [\n \"x-vercel-ip-country\",\n \"cloudfront-viewer-country\",\n \"cf-ipcountry\",\n \"x-country-code\"\n];\n\n/** The minimal data surface the render helper needs. ContentSdk satisfies this. */\nexport interface ExperimentSdk {\n getPage(path: string): Promise<PublicPage | null>;\n getPageExperiment(path: string): Promise<ActiveExperiment | null>;\n getVariantContent(variantId: string): Promise<VariantContent | null>;\n /** Runtime kill-switch check. When omitted, the pause state is not consulted. */\n getExperimentPaused?(experimentId: string): Promise<boolean>;\n}\n\nexport interface ExperimentCookie {\n name: string;\n value: string;\n maxAge: number;\n}\n\nexport interface GetPageWithExperimentOptions {\n /** Forced variant id (from the `?wb-variant=` query parameter). Overrides bucketing, never counted. */\n forcedVariantId?: string;\n /**\n * Request query parameters. When provided, the forced variant is read from\n * `searchParams[FORCED_VARIANT_PARAM]` unless `forcedVariantId` is set explicitly. Convenient\n * for framework handlers that already have the parsed query (e.g. Next.js `searchParams`).\n */\n searchParams?: Record<string, string | string[] | undefined>;\n /** Explicit visitor id. When omitted, it is read from the cookie or generated. */\n visitorId?: string;\n /** Explicit ISO country code. When omitted, it is read from common CDN geo headers. */\n country?: string;\n /** Explicit device type. When omitted, it is derived from the user agent. */\n device?: DeviceType;\n /** Header names to read the visitor country from, in priority order. */\n geoHeaders?: string[];\n /** Bucketing cookie name. Defaults to `wb_ab_vid`. */\n cookieName?: string;\n /** Bucketing cookie max age in seconds. Defaults to one year. */\n cookieMaxAge?: number;\n /** Set false to skip exposure emission (e.g. when consent has not been granted). */\n emitExposure?: boolean;\n /** Visitor id generator. Defaults to nanoid. */\n generateVisitorId?: () => string;\n}\n\nexport interface ExperimentRenderResult {\n /** The content to render server-side: the baseline (control) or a variant overlay. */\n page: PublicPage | null;\n /** The active experiment, or null when there is none for the path. */\n experiment: ActiveExperiment | null;\n /** The visitor's assignment, or null when no experiment applies. */\n assignment: VariantAssignment | null;\n /**\n * Set when the caller should persist the bucketing cookie (i.e. a visitor id was generated\n * this request). Persisting it in a Route Handler / middleware keeps bucketing stable.\n */\n cookie?: ExperimentCookie;\n}\n\nconst readCookie = (cookieHeader: string | null | undefined, name: string): string | undefined => {\n if (!cookieHeader) {\n return undefined;\n }\n for (const part of cookieHeader.split(\";\")) {\n const index = part.indexOf(\"=\");\n if (index === -1) {\n continue;\n }\n const key = part.slice(0, index).trim();\n if (key === name) {\n return decodeURIComponent(part.slice(index + 1).trim());\n }\n }\n return undefined;\n};\n\nconst detectDevice = (userAgent: string | null | undefined): DeviceType | undefined => {\n if (!userAgent) {\n return undefined;\n }\n if (/iPad|Tablet|PlayBook|Silk/i.test(userAgent)) {\n return \"tablet\";\n }\n if (/Mobi|Android.+Mobile|iPhone|iPod|Windows Phone/i.test(userAgent)) {\n return \"mobile\";\n }\n return \"desktop\";\n};\n\n/**\n * Resolve request-time visitor context from request headers/cookies. Never uses a logged-in\n * identity. Returns whether a visitor id was generated, so the caller can persist the cookie.\n */\nexport const resolveVisitorContext = async (\n options: GetPageWithExperimentOptions = {}\n): Promise<{ context: VisitorContext; generatedVisitorId: boolean }> => {\n const cookieName = options.cookieName ?? DEFAULT_VISITOR_COOKIE;\n const generate = options.generateVisitorId ?? (() => nanoid());\n\n let headers: Headers | undefined;\n const provider = getHeadersProvider();\n if (provider) {\n try {\n headers = await provider();\n } catch {\n headers = undefined;\n }\n }\n\n let visitorId = options.visitorId;\n let generatedVisitorId = false;\n if (!visitorId) {\n visitorId = readCookie(headers?.get(\"cookie\"), cookieName);\n }\n if (!visitorId) {\n visitorId = generate();\n generatedVisitorId = true;\n }\n\n let country = options.country;\n if (!country && headers) {\n for (const header of options.geoHeaders ?? DEFAULT_GEO_HEADERS) {\n const value = headers.get(header);\n if (value) {\n country = value.toUpperCase();\n break;\n }\n }\n }\n\n const device = options.device ?? detectDevice(headers?.get(\"user-agent\"));\n\n return { context: { visitorId, country, device }, generatedVisitorId };\n};\n\n/** Forced variant id from an explicit option, falling back to the query parameter. */\nconst resolveForcedVariantId = (options: GetPageWithExperimentOptions): string | undefined => {\n if (options.forcedVariantId) {\n return options.forcedVariantId;\n }\n const raw = options.searchParams?.[FORCED_VARIANT_PARAM];\n return Array.isArray(raw) ? raw[0] : raw;\n};\n\nconst overlayVariant = (baseline: PublicPage, variant: VariantContent): PublicPage => {\n return {\n ...baseline,\n properties: variant.properties as PublicPage[\"properties\"],\n bindings: variant.bindings as PublicPage[\"bindings\"],\n elements: variant.elements as PublicPage[\"elements\"],\n extensions: variant.extensions,\n metadata: variant.metadata\n };\n};\n\n/**\n * Resolve and render the right page for a visitor, server-side, with no client-side content\n * swap.\n *\n * Caching contract (important — handled here so projects do not reimplement it):\n * - The baseline (control) is fetched via `getPage(path)` and caches per path.\n * - The active experiment metadata is fetched via `getPageExperiment(path)` and caches per path.\n * - A variant's content is fetched via `getVariantContent(variantId)` and caches per variant id.\n * Every request therefore resolves to one of a small, fixed set of cacheable objects keyed by\n * (path) or (variant id) — NEVER by the raw visitor id. Each active variant adds exactly one\n * cacheable object per URL.\n */\nexport const getPageWithExperiment = async (\n sdk: ExperimentSdk,\n path: string,\n options: GetPageWithExperimentOptions = {}\n): Promise<ExperimentRenderResult> => {\n // Baseline content + active experiment. Both cache per path.\n const [controlPage, experiment] = await Promise.all([\n sdk.getPage(path),\n sdk.getPageExperiment(path)\n ]);\n\n if (!experiment || !controlPage) {\n return { page: controlPage, experiment: experiment ?? null, assignment: null };\n }\n\n // Only a running experiment serves variants. `getPageExperiment` already returns the active\n // one, but guard defensively.\n if (experiment.status !== \"running\") {\n return { page: controlPage, experiment, assignment: null };\n }\n\n // Kill-switch: a paused experiment instantly reverts to the control. Consulted uncached by the\n // data provider so it takes effect immediately while page/variant content stays cached.\n if (sdk.getExperimentPaused) {\n const paused = await sdk.getExperimentPaused(experiment.experimentId).catch(() => false);\n if (paused) {\n return { page: controlPage, experiment, assignment: null };\n }\n }\n\n const forcedVariantId = resolveForcedVariantId(options);\n\n const { context, generatedVisitorId } = await resolveVisitorContext(options);\n\n let assignment: VariantAssignment | null = null;\n if (forcedVariantId) {\n assignment = forcedAssignment(experiment, forcedVariantId);\n }\n if (!assignment) {\n assignment = assignVariant(experiment, context);\n }\n\n let page = controlPage;\n if (!assignment.isControl) {\n const variant = await sdk.getVariantContent(assignment.variantId);\n if (variant) {\n page = overlayVariant(controlPage, variant);\n } else {\n // The variant disappeared (e.g. deleted) — fall back to the control safely.\n assignment = {\n variantId: experiment.controlVariantId,\n isControl: true,\n excluded: assignment.excluded,\n forced: assignment.forced\n };\n }\n }\n\n // Emit a single canonical exposure event through the provider seam. Forced and excluded\n // assignments are never reported. Analytics failures must never break rendering.\n const shouldEmit = (options.emitExposure ?? true) && !assignment.excluded && !assignment.forced;\n if (shouldEmit) {\n const provider = getAnalyticsProvider(experiment.analytics.provider);\n if (provider) {\n const experimentKey =\n typeof experiment.analytics.experimentKey === \"string\"\n ? experiment.analytics.experimentKey\n : undefined;\n try {\n await provider.emitExposure({\n experimentId: experiment.experimentId,\n experimentKey,\n variantId: assignment.variantId,\n revisionId: experiment.revisionId,\n tenantId: experiment.tenantId,\n visitorId: context.visitorId,\n pageId: experiment.pageEntryId,\n path: experiment.path,\n timestamp: new Date().toISOString()\n });\n } catch {\n // Swallow — exposure measurement must never affect the rendered response.\n }\n }\n }\n\n const result: ExperimentRenderResult = { page, experiment, assignment };\n\n if (generatedVisitorId) {\n result.cookie = {\n name: options.cookieName ?? DEFAULT_VISITOR_COOKIE,\n value: context.visitorId,\n maxAge: options.cookieMaxAge ?? 60 * 60 * 24 * 365\n };\n }\n\n return result;\n};\n"],"names":["DEFAULT_VISITOR_COOKIE","FORCED_VARIANT_PARAM","DEFAULT_GEO_HEADERS","readCookie","cookieHeader","name","part","index","key","decodeURIComponent","detectDevice","userAgent","resolveVisitorContext","options","cookieName","generate","nanoid","headers","provider","getHeadersProvider","undefined","visitorId","generatedVisitorId","country","header","value","device","resolveForcedVariantId","raw","Array","overlayVariant","baseline","variant","getPageWithExperiment","sdk","path","controlPage","experiment","Promise","paused","forcedVariantId","context","assignment","forcedAssignment","assignVariant","page","shouldEmit","getAnalyticsProvider","experimentKey","Date","result"],"mappings":"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"}
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The reserved variant id for the control bucket. The control bucket renders the baseline
3
+ * revision content; it has no Variant object of its own.
4
+ */
5
+ export declare const CONTROL_VARIANT_ID = "control";
6
+ export type DeviceType = "desktop" | "mobile" | "tablet";
7
+ export interface ExperimentTrafficSplit {
8
+ control: number;
9
+ variants: Record<string, number>;
10
+ }
11
+ export interface ExperimentTargeting {
12
+ trafficPercentage: number;
13
+ geo?: string[];
14
+ device?: DeviceType[];
15
+ }
16
+ /**
17
+ * Provider-agnostic analytics configuration carried by an experiment. Adapters read this and
18
+ * map it to their own shape. No provider-specific field name appears here.
19
+ */
20
+ export interface ExperimentAnalyticsConfig {
21
+ provider: string;
22
+ [key: string]: unknown;
23
+ }
24
+ export interface ActiveExperimentVariant {
25
+ variantId: string;
26
+ name: string;
27
+ }
28
+ /**
29
+ * A variant's full content snapshot, shaped for rendering. Overlaid onto the baseline page so
30
+ * the result is a complete, renderable page.
31
+ */
32
+ export interface VariantContent {
33
+ id: string;
34
+ properties: Record<string, any>;
35
+ bindings: Record<string, any>;
36
+ elements: Record<string, any>;
37
+ extensions: Record<string, any>;
38
+ metadata: Record<string, any>;
39
+ }
40
+ /**
41
+ * The active experiment for a path as returned by the Website Builder API. This is the only
42
+ * experiment shape the render path deals with — it is provider-agnostic.
43
+ */
44
+ export interface ActiveExperiment {
45
+ experimentId: string;
46
+ revisionId: string;
47
+ pageEntryId: string;
48
+ path: string;
49
+ status: string;
50
+ tenantId: string;
51
+ controlVariantId: string;
52
+ trafficSplit: ExperimentTrafficSplit;
53
+ targeting: ExperimentTargeting;
54
+ analytics: ExperimentAnalyticsConfig;
55
+ variants: ActiveExperimentVariant[];
56
+ }
57
+ /**
58
+ * Request-time context used to bucket a visitor. Derived server-side from request headers /
59
+ * cookies — never from a logged-in identity.
60
+ */
61
+ export interface VisitorContext {
62
+ /** A stable visitor id (from the bucketing cookie, or a generated id). */
63
+ visitorId: string;
64
+ /** ISO country code, if the CDN/edge provided one. */
65
+ country?: string;
66
+ /** Device type, derived from the user agent. */
67
+ device?: DeviceType;
68
+ }
69
+ export interface VariantAssignment {
70
+ /** The assigned variant id, or the control variant id. */
71
+ variantId: string;
72
+ /** True when the control bucket (baseline revision) was assigned. */
73
+ isControl: boolean;
74
+ /**
75
+ * True when the visitor was not entered into the experiment (targeting or traffic
76
+ * percentage). The control is served and no exposure is emitted.
77
+ */
78
+ excluded: boolean;
79
+ /** True when a forced-variant query parameter overrode bucketing (QA). Never reported. */
80
+ forced: boolean;
81
+ }
82
+ /**
83
+ * The single canonical exposure event. Each analytics provider adapter maps from this into its
84
+ * own event shape. No PostHog (or other provider) field names may appear here.
85
+ */
86
+ export interface ExposureEvent {
87
+ experimentId: string;
88
+ /** Optional human-readable experiment key for provider mapping (from analytics config). */
89
+ experimentKey?: string;
90
+ /** The assigned variant id (the control variant id for the control bucket). */
91
+ variantId: string;
92
+ revisionId: string;
93
+ tenantId: string;
94
+ /** Stable distinct visitor id. */
95
+ visitorId: string;
96
+ pageId: string;
97
+ path: string;
98
+ timestamp: string;
99
+ }
100
+ /**
101
+ * The provider seam. A v2 GA4 adapter is a drop-in implementation of this interface — the data
102
+ * model, assignment logic, and render path never change.
103
+ */
104
+ export interface AnalyticsProvider {
105
+ /** Provider id, matched against an experiment's analytics.provider. */
106
+ readonly name: string;
107
+ emitExposure(event: ExposureEvent): Promise<void>;
108
+ }
@@ -0,0 +1,4 @@
1
+ const CONTROL_VARIANT_ID = "control";
2
+ export { CONTROL_VARIANT_ID };
3
+
4
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"experiments/types.js","sources":["../../src/experiments/types.ts"],"sourcesContent":["/**\n * The reserved variant id for the control bucket. The control bucket renders the baseline\n * revision content; it has no Variant object of its own.\n */\nexport const CONTROL_VARIANT_ID = \"control\";\n\nexport type DeviceType = \"desktop\" | \"mobile\" | \"tablet\";\n\nexport interface ExperimentTrafficSplit {\n control: number;\n variants: Record<string, number>;\n}\n\nexport interface ExperimentTargeting {\n trafficPercentage: number;\n geo?: string[];\n device?: DeviceType[];\n}\n\n/**\n * Provider-agnostic analytics configuration carried by an experiment. Adapters read this and\n * map it to their own shape. No provider-specific field name appears here.\n */\nexport interface ExperimentAnalyticsConfig {\n provider: string;\n [key: string]: unknown;\n}\n\nexport interface ActiveExperimentVariant {\n variantId: string;\n name: string;\n}\n\n/**\n * A variant's full content snapshot, shaped for rendering. Overlaid onto the baseline page so\n * the result is a complete, renderable page.\n */\nexport interface VariantContent {\n id: string;\n properties: Record<string, any>;\n bindings: Record<string, any>;\n elements: Record<string, any>;\n extensions: Record<string, any>;\n metadata: Record<string, any>;\n}\n\n/**\n * The active experiment for a path as returned by the Website Builder API. This is the only\n * experiment shape the render path deals with — it is provider-agnostic.\n */\nexport interface ActiveExperiment {\n experimentId: string;\n revisionId: string;\n pageEntryId: string;\n path: string;\n status: string;\n tenantId: string;\n controlVariantId: string;\n trafficSplit: ExperimentTrafficSplit;\n targeting: ExperimentTargeting;\n analytics: ExperimentAnalyticsConfig;\n variants: ActiveExperimentVariant[];\n}\n\n/**\n * Request-time context used to bucket a visitor. Derived server-side from request headers /\n * cookies — never from a logged-in identity.\n */\nexport interface VisitorContext {\n /** A stable visitor id (from the bucketing cookie, or a generated id). */\n visitorId: string;\n /** ISO country code, if the CDN/edge provided one. */\n country?: string;\n /** Device type, derived from the user agent. */\n device?: DeviceType;\n}\n\nexport interface VariantAssignment {\n /** The assigned variant id, or the control variant id. */\n variantId: string;\n /** True when the control bucket (baseline revision) was assigned. */\n isControl: boolean;\n /**\n * True when the visitor was not entered into the experiment (targeting or traffic\n * percentage). The control is served and no exposure is emitted.\n */\n excluded: boolean;\n /** True when a forced-variant query parameter overrode bucketing (QA). Never reported. */\n forced: boolean;\n}\n\n/**\n * The single canonical exposure event. Each analytics provider adapter maps from this into its\n * own event shape. No PostHog (or other provider) field names may appear here.\n */\nexport interface ExposureEvent {\n experimentId: string;\n /** Optional human-readable experiment key for provider mapping (from analytics config). */\n experimentKey?: string;\n /** The assigned variant id (the control variant id for the control bucket). */\n variantId: string;\n revisionId: string;\n tenantId: string;\n /** Stable distinct visitor id. */\n visitorId: string;\n pageId: string;\n path: string;\n timestamp: string;\n}\n\n/**\n * The provider seam. A v2 GA4 adapter is a drop-in implementation of this interface — the data\n * model, assignment logic, and render path never change.\n */\nexport interface AnalyticsProvider {\n /** Provider id, matched against an experiment's analytics.provider. */\n readonly name: string;\n emitExposure(event: ExposureEvent): Promise<void>;\n}\n"],"names":["CONTROL_VARIANT_ID"],"mappings":"AAIO,MAAMA,qBAAqB"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure geometry core for crop + focal point rendering.
3
+ *
4
+ * This module is the single source of truth shared by:
5
+ * - the Admin editor previews (square / 4:3 / 16:9),
6
+ * - the Next.js render helper (`getImageProps` / `<Image>`),
7
+ * - and any future URL-transform loader (`?rect=…`).
8
+ *
9
+ * Keeping the math here guarantees that what an editor previews is exactly what
10
+ * a visitor sees. Everything is framework-agnostic and side-effect free.
11
+ */
12
+ import type { AssetCrop, AssetImage } from "@webiny/sdk";
13
+ import type { AspectRatioInput } from "./types.js";
14
+ /** A rectangle in normalized (0..1) coordinates of the original image. */
15
+ export interface NormalizedRect {
16
+ x: number;
17
+ y: number;
18
+ width: number;
19
+ height: number;
20
+ }
21
+ /**
22
+ * CSS needed to render an image cropped to a target aspect ratio while honoring
23
+ * both the crop rectangle and the focal point — without any server-side processing.
24
+ *
25
+ * `objectPosition` is the simpler, focal-point-only path (apply to an `object-fit:
26
+ * cover` image). `container` + `image` is the accurate path that also honors the
27
+ * crop rectangle via an overflow-clipped wrapper.
28
+ */
29
+ export interface ImageRenderData {
30
+ rect: NormalizedRect;
31
+ objectPosition: string;
32
+ container: {
33
+ position: "relative";
34
+ width: "100%";
35
+ aspectRatio: string;
36
+ overflow: "hidden";
37
+ };
38
+ image: {
39
+ position: "absolute";
40
+ width: string;
41
+ height: string;
42
+ left: string;
43
+ top: string;
44
+ maxWidth: "none";
45
+ objectFit: "cover";
46
+ objectPosition: string;
47
+ };
48
+ intrinsicWidth: number;
49
+ intrinsicHeight: number;
50
+ }
51
+ export declare function resolveAssetImage(override?: AssetImage | null, assetDefault?: AssetImage | null): AssetImage;
52
+ export declare function getCropRect(crop?: AssetCrop | null): NormalizedRect;
53
+ export declare function getVisibleRect(image: AssetImage, aspectRatio?: AspectRatioInput): NormalizedRect;
54
+ export declare function getImageRenderData(image: AssetImage, aspectRatio?: AspectRatioInput): ImageRenderData;