@web-my-money/studio-consumer 1.0.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.
@@ -0,0 +1,27 @@
1
+ /** Proves at runtime which build a consumer is actually running. */
2
+ export const PACKAGE_VERSION = "1.0.0";
3
+
4
+ export {
5
+ createContentClient,
6
+ type ContentClient,
7
+ type ContentManifest,
8
+ type ManifestSlot,
9
+ type StudioForm,
10
+ } from "./payload";
11
+ export { createManifestHandler } from "./manifest-handler";
12
+ export { createRevalidateHandler } from "./revalidate-handler";
13
+ export { studioFrameAncestors } from "./headers";
14
+ export {
15
+ overrideValueForDict,
16
+ resolveLocalized,
17
+ setPath,
18
+ withSlotOverride,
19
+ } from "./dict-overrides";
20
+ export {
21
+ WmmEditOverlay,
22
+ StudioSlotPreviewBridge,
23
+ StudioFormPreviewBridge,
24
+ type PreviewableSlot,
25
+ type StudioPreviewFormProps,
26
+ type StudioPreviewFormComponent,
27
+ } from "./preview";
@@ -0,0 +1,34 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { ContentManifest } from "./payload";
3
+
4
+ /**
5
+ * Public content manifest route handler — what this site declares as editable,
6
+ * with each slot's current code baseline resolved from the app's own dictionary.
7
+ * Studio pulls this to reconcile its slots (structure + defaults; overrides
8
+ * untouched). Public and non-sensitive: it only exposes copy that already ships
9
+ * in the site's HTML.
10
+ *
11
+ * Takes the app's own `getManifest` so the manifest itself — the content model —
12
+ * stays in the consuming app, not in this package.
13
+ *
14
+ * The consumer's route file must ALSO export `export const dynamic =
15
+ * "force-dynamic";` alongside `export const GET = createManifestHandler(...)`.
16
+ * The source route carried that export at module scope, and Next requires it
17
+ * literally in the route file — re-exporting it through this factory would not
18
+ * register with Next's route config, so this package cannot carry it for you.
19
+ * Leaving it off silently changes the route from always-fresh to
20
+ * statically-cached at build time, which means Studio's manifest sync would
21
+ * reconcile against stale slot defaults instead of the live dictionary.
22
+ */
23
+ export function createManifestHandler(
24
+ getManifest: () => Promise<ContentManifest>,
25
+ ): () => Promise<Response> {
26
+ return async function GET() {
27
+ const manifest = await getManifest();
28
+ return NextResponse.json(manifest, {
29
+ headers: {
30
+ "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
31
+ },
32
+ });
33
+ };
34
+ }
@@ -0,0 +1,414 @@
1
+ import "server-only";
2
+ import { cache } from "react";
3
+ import { cookies, headers } from "next/headers";
4
+ // Shared with the Studio slot-preview bridge, which runs this same merge in the
5
+ // browser. One implementation on purpose — see content/dict-overrides.ts.
6
+ import { overrideValueForDict, setPath } from "./dict-overrides";
7
+ import {
8
+ CONTROL,
9
+ VARIANT,
10
+ VISITOR_COOKIE,
11
+ resolveVariant,
12
+ type RunningExperiment,
13
+ } from "../analytics/bucketing";
14
+
15
+ /**
16
+ * Studio content reader (spec: Feature B — zero secrets).
17
+ *
18
+ * Reads the site's human OVERRIDES from Studio's PUBLIC content API — no Supabase
19
+ * credentials, no service key. The only config is `studioUrl` (a public,
20
+ * non-secret URL). The override map is fetched once per render (React `cache`
21
+ * dedupes within a request; Next ISR-caches it across requests and the revalidate
22
+ * webhook busts it). The code copy (dictionary/fallback) is always the base;
23
+ * only overrides are layered on top, so a slot tracking code can't go stale.
24
+ * Any failure returns the caller's fallback, so the site never breaks if Studio
25
+ * is down, unset, or a slot is missing.
26
+ */
27
+
28
+ /**
29
+ * A form the team authored in Studio rather than in this repo's manifest (§4.3 —
30
+ * see docs/studio-authored-forms.md).
31
+ *
32
+ * `key` is the slot key and the CRM-facing contract; `slug` is the public URL
33
+ * segment, kept separate so the URL can be renamed without moving the CRM
34
+ * mapping; `listed` is the explicit "make it public" flag, which new forms start
35
+ * without so a half-built funnel is never indexable.
36
+ */
37
+ export type StudioForm = { key: string; slug: string; listed: boolean };
38
+
39
+ /**
40
+ * The minimal shape `applyDictOverrides` needs from the consuming app's own
41
+ * content manifest. The manifest itself stays in the app (its slots are the
42
+ * app's content model) — this client only ever reads `key`/`type`/`dictPath`
43
+ * off of it.
44
+ */
45
+ export interface ManifestSlot {
46
+ key: string;
47
+ type: string;
48
+ dictPath?: string;
49
+ }
50
+
51
+ export interface ContentManifest {
52
+ slots: ManifestSlot[];
53
+ }
54
+
55
+ type ContentPayload = {
56
+ content?: Record<string, unknown>;
57
+ /** Absent on a Studio deploy predating §4.3 — read as "no authored forms". */
58
+ forms?: unknown;
59
+ /** Absent on a Studio deploy predating A/B — read as "no experiments running". */
60
+ experiments?: unknown;
61
+ /**
62
+ * Absent on a Studio deploy predating winner declarations — read as "no
63
+ * decided test is overriding anything", which is the safe answer: everyone
64
+ * gets control, exactly as before this existed.
65
+ */
66
+ decidedWinners?: unknown;
67
+ };
68
+
69
+ export interface ContentClient {
70
+ getSlot<T>(slotKey: string, fallback: T): Promise<T>;
71
+ getLocalizedSlot(slotKey: string, locale: string, fallback: string): Promise<string>;
72
+ applyDictOverrides<T>(dict: T, locale: string): Promise<T>;
73
+ activeVariant(): Promise<string>;
74
+ getRunningExperiments(): Promise<RunningExperiment[]>;
75
+ getDecidedWinners(): Promise<{ targetPath: string; winner: string }[]>;
76
+ getStudioForms(): Promise<StudioForm[]>;
77
+ }
78
+
79
+ /**
80
+ * Build a content client bound to one site.
81
+ *
82
+ * Call this ONCE, at module scope in the consuming app, and reuse the returned
83
+ * client everywhere — same reason `lib/content-manifest.ts`'s `getManifest` is a
84
+ * singleton: the `cache()` wrapping `getPayload` and `activeVariant` below dedupes
85
+ * per React render by closing over ONE function reference, so a client rebuilt on
86
+ * every call would defeat that dedupe and let one page's slots disagree about the
87
+ * arm.
88
+ */
89
+ export function createContentClient({
90
+ studioUrl,
91
+ siteKey,
92
+ getManifest,
93
+ }: {
94
+ /** Studio's public content origin, or null/undefined if unset — a public,
95
+ * non-secret URL. */
96
+ studioUrl: string | null | undefined;
97
+ siteKey: string;
98
+ /** The app's own manifest — stays in the app; this client reads only
99
+ * `slots[].{key,type,dictPath}` off of it. */
100
+ getManifest: () => Promise<ContentManifest>;
101
+ }): ContentClient {
102
+ function studioBase(): string | null {
103
+ return studioUrl ? studioUrl.replace(/\/+$/, "") : null;
104
+ }
105
+
106
+ /**
107
+ * One fetch for the whole payload — overrides AND the published-forms list.
108
+ *
109
+ * Deliberately not two requests. The forms list is the second source of the
110
+ * submit route's allow-list, consulted on every form POST, so giving it its own
111
+ * round trip would put an extra network call in the money path. Riding here means
112
+ * it shares the same React `cache` (deduped per render), the same ISR window and
113
+ * the same revalidate tag, and costs nothing.
114
+ */
115
+ const getPayload = cache(async (): Promise<ContentPayload | null> => {
116
+ const base = studioBase();
117
+ if (!base) return null;
118
+ try {
119
+ const res = await fetch(`${base}/api/public/content/${siteKey}`, {
120
+ // ISR: keep slot-backed pages static; the revalidate webhook invalidates
121
+ // on edit, and this TTL is the freshness backstop if the webhook is missed.
122
+ next: { revalidate: 30, tags: ["wmm-content"] },
123
+ });
124
+ if (!res.ok) return null;
125
+ return (await res.json()) as ContentPayload;
126
+ } catch {
127
+ return null;
128
+ }
129
+ });
130
+
131
+ async function getContent(): Promise<Record<string, unknown> | null> {
132
+ return (await getPayload())?.content ?? null;
133
+ }
134
+
135
+ /**
136
+ * The forms Studio has published, as the submit allow-list's second source.
137
+ *
138
+ * Parsed defensively rather than trusted. This list widens which slots may write
139
+ * to the CRM, so a malformed or partial entry is DROPPED rather than guessed at —
140
+ * an entry missing its key or slug is not a form we can safely accept. A Studio
141
+ * that doesn't serve `forms` yields an empty list, which is exactly the
142
+ * pre-§4.3 behaviour, so the two repos can deploy in either order.
143
+ */
144
+ async function getStudioForms(): Promise<StudioForm[]> {
145
+ const raw = (await getPayload())?.forms;
146
+ if (!Array.isArray(raw)) return [];
147
+ const out: StudioForm[] = [];
148
+ for (const item of raw) {
149
+ if (!item || typeof item !== "object") continue;
150
+ const f = item as { key?: unknown; slug?: unknown; listed?: unknown };
151
+ if (typeof f.key !== "string" || f.key.length === 0) continue;
152
+ if (typeof f.slug !== "string" || f.slug.length === 0) continue;
153
+ // `listed` must be explicitly true. Anything else — absent, null, "false",
154
+ // 0 — means unlisted, so a malformed payload cannot accidentally publish a
155
+ // half-built funnel to search engines.
156
+ out.push({ key: f.key, slug: f.slug, listed: f.listed === true });
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * The experiments Studio has running, for server-side A/B assignment
163
+ * (wmm-studio docs/analytics/06-ab-testing.md §2.3).
164
+ *
165
+ * Rides the same payload as content and forms, so assignment adds no request and
166
+ * no database call to a public page — it reads a value the page already fetched
167
+ * and ISR-cached.
168
+ *
169
+ * Parsed defensively, and a malformed entry is DROPPED rather than repaired. The
170
+ * consequence of guessing is worse than the consequence of not running the test:
171
+ * a bad `split` would send everyone one way while the data still claimed to be an
172
+ * experiment, which is a wrong answer rather than no answer. `../analytics/bucketing`
173
+ * defends against the same thing again at assignment time.
174
+ */
175
+ async function getRunningExperiments(): Promise<RunningExperiment[]> {
176
+ const raw = (await getPayload())?.experiments;
177
+ if (!Array.isArray(raw)) return [];
178
+ const out: RunningExperiment[] = [];
179
+ for (const item of raw) {
180
+ if (!item || typeof item !== "object") continue;
181
+ const e = item as { key?: unknown; targetPath?: unknown; split?: unknown };
182
+ if (typeof e.key !== "string" || e.key.length === 0) continue;
183
+ if (typeof e.targetPath !== "string" || e.targetPath.length === 0) continue;
184
+ if (typeof e.split !== "number" || !Number.isFinite(e.split)) continue;
185
+ if (e.split < 1 || e.split > 99) continue;
186
+ out.push({ key: e.key, targetPath: e.targetPath, split: e.split });
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * Declared A/B winners whose cleanup has not been confirmed
193
+ * (wmm-studio migration 0019).
194
+ *
195
+ * A test that has been decided serves its WINNING arm to every visitor on that
196
+ * path, with no deploy and no bucketing. That is what makes "declare a winner"
197
+ * move 100% of traffic: a `<key>__b` value and a page that branches on
198
+ * `activeVariant() === 'b'` both follow, because both read the same arm.
199
+ *
200
+ * The override is temporary by design. Studio drops the row from this payload
201
+ * once someone confirms the cleanup — by then the winning content has been
202
+ * promoted onto the control keys and the code branch has shipped unconditionally,
203
+ * so there is nothing left to override and the page is testable again.
204
+ *
205
+ * Parsed as defensively as the running experiments above, and for a sharper
206
+ * reason: an unrecognised winner must not resolve to an arm. Serving `b` on a
207
+ * guess would put every visitor on content nobody chose.
208
+ */
209
+ async function getDecidedWinners(): Promise<
210
+ { targetPath: string; winner: string }[]
211
+ > {
212
+ const raw = (await getPayload())?.decidedWinners;
213
+ if (!Array.isArray(raw)) return [];
214
+ const out: { targetPath: string; winner: string }[] = [];
215
+ for (const item of raw) {
216
+ if (!item || typeof item !== "object") continue;
217
+ const e = item as { targetPath?: unknown; winner?: unknown };
218
+ if (typeof e.targetPath !== "string" || e.targetPath.length === 0) continue;
219
+ // Only the two arms that exist. Anything else is dropped, not coerced.
220
+ if (e.winner !== CONTROL && e.winner !== VARIANT) continue;
221
+ out.push({ targetPath: e.targetPath, winner: e.winner });
222
+ }
223
+ return out;
224
+ }
225
+
226
+ /**
227
+ * The A/B variant for the current request: `control` or `b`.
228
+ *
229
+ * Reads the path from the header middleware set and the visitor id from the
230
+ * `wmm_vid` cookie, then resolves against the running experiments in the content
231
+ * payload. `cache`d, so every slot lookup in one render agrees — a page that
232
+ * resolved half its copy as control and half as B would be unmeasurable.
233
+ *
234
+ * ## A STATICALLY RENDERED PAGE CANNOT VARY. Read this before running a test.
235
+ *
236
+ * `cookies()` and `headers()` throw during static generation — that is how Next
237
+ * decides a route must be server-rendered. This function CATCHES that and returns
238
+ * control, which is a deliberate trade with a sharp edge:
239
+ *
240
+ * - **Kept:** every page that was statically generated still is. `getSlot` runs
241
+ * on every page through the dictionary merge, so re-throwing would turn the
242
+ * whole site dynamic and lose ISR — a large, permanent cost for a feature used
243
+ * a few times a year.
244
+ * - **Cost:** a statically generated page is rendered once, at build time, as
245
+ * control. An experiment targeting it will assign visitors, record their
246
+ * variant on every event, and show a comparison — while **both arms see
247
+ * identical copy**. The numbers would look valid and mean nothing.
248
+ *
249
+ * So a page under test must opt into dynamic rendering:
250
+ *
251
+ * ```ts
252
+ * // app/[lang]/[country]/med-spa/page.tsx
253
+ * export const dynamic = "force-dynamic"; // required while an A/B test runs here
254
+ * ```
255
+ *
256
+ * `docs/funnel-analytics.md` carries the same warning next to the run-a-test
257
+ * steps. The better long-term fix is a middleware rewrite that gives each variant
258
+ * its own cache entry, which keeps static rendering AND varies — not built.
259
+ */
260
+ const activeVariant = cache(async (): Promise<string> => {
261
+ try {
262
+ const [headerList, cookieStore, experiments, decided] = await Promise.all([
263
+ headers(),
264
+ cookies(),
265
+ getRunningExperiments(),
266
+ getDecidedWinners(),
267
+ ]);
268
+ /*
269
+ The editor's preview override, set by the proxy from `?wmm-variant=b`.
270
+ Checked before the experiment lookup because it exists precisely for the
271
+ case where there is no experiment yet: Studio's workflow is make a
272
+ variant, look at it, THEN launch, and without this the editor renders
273
+ control while claiming to show B. Preview traffic is marked internal by
274
+ the collector, so this cannot feed the arm it is previewing.
275
+ */
276
+ if (headerList.get("x-wmm-variant") === VARIANT) return VARIANT;
277
+
278
+ const path = headerList.get("x-wmm-path");
279
+ if (!path) return CONTROL;
280
+
281
+ /*
282
+ A RUNNING test wins over a declared winner, and the order matters.
283
+
284
+ Studio refuses to start a test on a path whose previous test is decided but
285
+ not cleaned up, so in practice these never overlap. Resolving bucketing
286
+ first anyway means that if they ever did — a stale payload, a hand-edited
287
+ row — the live test keeps working rather than being silently overridden by
288
+ an old verdict, which is the failure that would be hardest to notice.
289
+ */
290
+ const bucketed = resolveVariant(
291
+ path,
292
+ cookieStore.get(VISITOR_COOKIE)?.value,
293
+ experiments,
294
+ );
295
+ if (bucketed.experimentKey) return bucketed.variant;
296
+
297
+ /*
298
+ No test running: a declared winner takes 100% of this path.
299
+
300
+ Exact path match, like `resolveVariant` — a prefix match would apply a
301
+ home-page verdict to every page under it.
302
+ */
303
+ const winner = decided.find((d) => d.targetPath === path);
304
+ return winner ? winner.winner : CONTROL;
305
+ } catch {
306
+ // Static render, or middleware did not run. Control is the only safe answer:
307
+ // serving a variant by accident would put untracked visitors into an arm.
308
+ //
309
+ // See the warning above — this is also the path that makes an experiment on a
310
+ // static page silently ineffective.
311
+ return CONTROL;
312
+ }
313
+ });
314
+
315
+ /**
316
+ * A slot's Studio override, or `fallback` (the code copy) when not overridden.
317
+ *
318
+ * **A/B: a `__b` suffix on any slot key is that slot's variant B.** When an
319
+ * experiment is running for this path and this visitor is bucketed into B, a
320
+ * published `hero.title__b` is used in place of `hero.title`. If no `__b` value
321
+ * exists the visitor simply sees control, so a half-authored experiment degrades
322
+ * to no experiment rather than to a broken page.
323
+ *
324
+ * This is what makes a test runnable with **no code change**: every
325
+ * Studio-editable slot on the site becomes testable by publishing one extra
326
+ * value. Same principle as instrumenting the form renderer once — the alternative
327
+ * is a code deploy per experiment, which is how A/B testing quietly stops
328
+ * happening.
329
+ */
330
+ async function getSlot<T>(slotKey: string, fallback: T): Promise<T> {
331
+ const content = await getContent();
332
+
333
+ if (content) {
334
+ const variant = await activeVariant();
335
+ if (variant === VARIANT) {
336
+ const override = content[`${slotKey}__${VARIANT}`];
337
+ if (override !== undefined && override !== null) return override as T;
338
+ }
339
+ }
340
+
341
+ const override = content?.[slotKey];
342
+ return (override ?? fallback) as T;
343
+ }
344
+
345
+ /**
346
+ * Convenience for localized `{ en, es }` text slots: returns the string for the
347
+ * given locale ("en-US" / "es-US"), falling back to EN, then to `fallback`
348
+ * (typically the current dictionary value).
349
+ */
350
+ async function getLocalizedSlot(
351
+ slotKey: string,
352
+ locale: string,
353
+ fallback: string,
354
+ ): Promise<string> {
355
+ const value = await getSlot<{ en?: string; es?: string } | null>(slotKey, null);
356
+ if (!value) return fallback;
357
+ const isEs = locale.toLowerCase().startsWith("es");
358
+ return (isEs ? value.es : value.en) || value.en || fallback;
359
+ }
360
+
361
+ /**
362
+ * Deep-merge the site's Studio overrides onto a dictionary for `locale`
363
+ * (dict-merge pattern — spec Phase 5 / Option A). For each manifest slot with a
364
+ * `dictPath`, if the team overrode it in Studio, the override is resolved for
365
+ * `locale` and written into a clone of the dict at that path. Slots tracking
366
+ * code are left untouched, so the dict (live code copy) stays the base. Called
367
+ * once in the [lang]/[country] layout, so every dict-backed component becomes
368
+ * editable with no component changes. Returns the original dict unchanged if
369
+ * Studio is unset/unreachable.
370
+ *
371
+ * Slot shapes handled:
372
+ * - text/richtext → a `{ en, es }` object resolved to the locale string.
373
+ * - list → a `{ items: [...] }` object; each item is flattened so any
374
+ * `{ en, es }` field (e.g. a testimonial `quote`) becomes the locale string,
375
+ * matching what the dict-driven component expects (e.g. `reviews[]`).
376
+ */
377
+ async function applyDictOverrides<T>(dict: T, locale: string): Promise<T> {
378
+ const overrides = await getContent();
379
+ if (!overrides) return dict;
380
+
381
+ const manifest = await getManifest();
382
+ const isEs = locale.toLowerCase().startsWith("es");
383
+ const clone = structuredClone(dict);
384
+ // Most of the site's copy arrives through this function rather than through
385
+ // getSlot, so the `__b` mechanism has to apply here too or an experiment could
386
+ // only ever test the handful of directly-read slots.
387
+ const variant = await activeVariant();
388
+
389
+ for (const slot of manifest.slots) {
390
+ if (!slot.dictPath) continue;
391
+
392
+ const raw =
393
+ variant === VARIANT && overrides[`${slot.key}__${VARIANT}`] !== undefined
394
+ ? overrides[`${slot.key}__${VARIANT}`]
395
+ : overrides[slot.key];
396
+
397
+ const value = overrideValueForDict(slot.type, raw, isEs);
398
+ if (value === undefined) continue;
399
+
400
+ setPath(clone as Record<string, unknown>, slot.dictPath, value);
401
+ }
402
+ return clone;
403
+ }
404
+
405
+ return {
406
+ getSlot,
407
+ getLocalizedSlot,
408
+ applyDictOverrides,
409
+ activeVariant,
410
+ getRunningExperiments,
411
+ getDecidedWinners,
412
+ getStudioForms,
413
+ };
414
+ }