@web-my-money/studio-consumer 1.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -4
- package/package.json +4 -2
- package/src/analytics/collect-handler.ts +70 -4
- package/src/analytics/index.ts +1 -1
- package/src/attribution/index.ts +1 -1
- package/src/brand/index.ts +66 -0
- package/src/content/index.ts +1 -1
- package/src/content/manifest-handler.ts +23 -4
- package/src/image/index.ts +14 -0
package/README.md
CHANGED
|
@@ -12,8 +12,77 @@ for no gain.
|
|
|
12
12
|
- `@web-my-money/studio-consumer/analytics`
|
|
13
13
|
- `@web-my-money/studio-consumer/content`
|
|
14
14
|
- `@web-my-money/studio-consumer/attribution`
|
|
15
|
+
- `@web-my-money/studio-consumer/headers`
|
|
16
|
+
- `@web-my-money/studio-consumer/brand` (2.0.0)
|
|
17
|
+
- `@web-my-money/studio-consumer/image` (2.1.0)
|
|
18
|
+
|
|
19
|
+
Each entry point exports `PACKAGE_VERSION`, to prove at runtime that a consumer
|
|
20
|
+
is resolving this package rather than a stale copy.
|
|
21
|
+
|
|
22
|
+
## Brand (2.0.0, breaking)
|
|
23
|
+
|
|
24
|
+
A site declares its brand theme in code. Every invalid input throws, and this
|
|
25
|
+
runs while the root layout renders, so a site with no brand, a malformed theme
|
|
26
|
+
id, or WMM's own theme fails its build instead of silently inheriting WMM's
|
|
27
|
+
palette.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// lib/brand.ts — its own module: Next refuses unknown named exports from a layout file
|
|
31
|
+
import { defineSiteBrand } from "@web-my-money/studio-consumer/brand";
|
|
32
|
+
|
|
33
|
+
export const brand = defineSiteBrand({ theme: "acme", style: "flat" });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
// app/layout.tsx
|
|
38
|
+
import { brandRootAttributes } from "@web-my-money/studio-consumer/brand";
|
|
39
|
+
import { brand } from "@/lib/brand";
|
|
40
|
+
|
|
41
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
42
|
+
return <html lang="en" {...brandRootAttributes(brand)}><body>{children}</body></html>;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// app/api/content/manifest/route.ts
|
|
48
|
+
import { brand } from "@/lib/brand";
|
|
49
|
+
|
|
50
|
+
export const GET = createManifestHandler(getManifest, brand);
|
|
51
|
+
export const dynamic = "force-dynamic";
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`createManifestHandler` now REQUIRES the brand (the breaking change in 2.0.0), so
|
|
55
|
+
Studio records the theme the site ships. Only a WMM site may use a WMM theme
|
|
56
|
+
(`wmm`, `site`, `light`, `dark`), by passing `wmmSite: true`.
|
|
57
|
+
|
|
58
|
+
## Image focal point (2.1.0)
|
|
59
|
+
|
|
60
|
+
An image value edited in Studio can carry `focal: { x, y }` (0–1 from the
|
|
61
|
+
top-left): where the subject is. Apply it so responsive crops keep the subject
|
|
62
|
+
in frame. No focal point, or a malformed one, reads as the centre, which is the
|
|
63
|
+
browser default, so existing images render exactly as before.
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { focalObjectPosition } from "@web-my-money/studio-consumer/image";
|
|
67
|
+
|
|
68
|
+
<img src={value.url} alt={value.alt.en} style={{ objectFit: "cover", objectPosition: focalObjectPosition(value) }} />
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Analytics collector (2.2.0)
|
|
72
|
+
|
|
73
|
+
`createCollectHandler` only accepts posts from the site's own pages (`Sec-Fetch-Site`,
|
|
74
|
+
falling back to `Origin`), and it decides each event's A/B arm on the server from the
|
|
75
|
+
`wmm_vid` cookie. The browser's own `variant` is always discarded. Pass the content
|
|
76
|
+
client's `getRunningExperiments` so the arm can be resolved:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
export const POST = createCollectHandler({
|
|
80
|
+
siteKey: SITE_KEY,
|
|
81
|
+
ingestUrl: process.env.FUNNEL_INGEST_URL ?? "",
|
|
82
|
+
ingestKey: process.env.FUNNEL_INGEST_TOKEN ?? "",
|
|
83
|
+
getRunningExperiments: () => content.getRunningExperiments(),
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Without it, events carry no arm and the site stays out of any A/B comparison.
|
|
15
88
|
|
|
16
|
-
This package is currently a skeleton: each entry point exports only a
|
|
17
|
-
`PACKAGE_VERSION` constant, used to prove at runtime that a consumer is
|
|
18
|
-
resolving this package rather than a stale copy. Content, analytics, and
|
|
19
|
-
attribution logic land in later tasks.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@web-my-money/studio-consumer",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Consumer-side integration for WMM Studio: content, analytics, attribution.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"./analytics": "./src/analytics/index.ts",
|
|
18
18
|
"./content": "./src/content/index.ts",
|
|
19
19
|
"./attribution": "./src/attribution/index.ts",
|
|
20
|
-
"./headers": "./src/content/headers.mjs"
|
|
20
|
+
"./headers": "./src/content/headers.mjs",
|
|
21
|
+
"./brand": "./src/brand/index.ts",
|
|
22
|
+
"./image": "./src/image/index.ts"
|
|
21
23
|
},
|
|
22
24
|
"peerDependencies": {
|
|
23
25
|
"next": ">=16",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { resolveVariant, VISITOR_COOKIE, type RunningExperiment } from "./bucketing";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* First-party analytics collector (wmm-studio docs/analytics/01-pipeline.md §5.1).
|
|
@@ -13,6 +14,9 @@ import { NextResponse } from "next/server";
|
|
|
13
14
|
* surface an error on a marketing page or interfere with a form — so failures
|
|
14
15
|
* are logged here and are invisible to the visitor.
|
|
15
16
|
*
|
|
17
|
+
* Same-origin only, and the A/B arm is resolved HERE from the visitor cookie,
|
|
18
|
+
* never taken from the payload (parity with WMM Website's own route, 2026-10-09).
|
|
19
|
+
*
|
|
16
20
|
* Inert with no `FUNNEL_INGEST_URL`: nothing is forwarded and the site behaves
|
|
17
21
|
* exactly as it did before this route existed. That is the correct state before
|
|
18
22
|
* rollout, which is what lets this merge ahead of being switched on.
|
|
@@ -61,6 +65,34 @@ function warnOnceAboutKeyShape(key: string): void {
|
|
|
61
65
|
);
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Only the site's own pages post here. Browsers send `Sec-Fetch-Site` (or at least
|
|
70
|
+
* an `Origin`) on a same-origin beacon; a bare script or a third-party page does
|
|
71
|
+
* not. Headers can be forged by hand, so this is a speed bump, not a lock; what
|
|
72
|
+
* actually protects the A/B numbers is the server-side arm below.
|
|
73
|
+
*/
|
|
74
|
+
function isSameOrigin(req: Request): boolean {
|
|
75
|
+
const fetchSite = req.headers.get("sec-fetch-site");
|
|
76
|
+
if (fetchSite) return fetchSite === "same-origin";
|
|
77
|
+
const origin = req.headers.get("origin");
|
|
78
|
+
if (!origin) return false;
|
|
79
|
+
try {
|
|
80
|
+
return new URL(origin).host === new URL(req.url).host;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readCookie(req: Request, name: string): string | null {
|
|
87
|
+
const header = req.headers.get("cookie");
|
|
88
|
+
if (!header) return null;
|
|
89
|
+
for (const part of header.split(";")) {
|
|
90
|
+
const [k, ...v] = part.trim().split("=");
|
|
91
|
+
if (k === name) return decodeURIComponent(v.join("="));
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
64
96
|
/** Test-only: forget that the one-time warning has already been printed. */
|
|
65
97
|
export function resetIngestKeyShapeWarning(): void {
|
|
66
98
|
warnedAboutKeyShape = false;
|
|
@@ -70,6 +102,13 @@ export function createCollectHandler(config: {
|
|
|
70
102
|
siteKey: string;
|
|
71
103
|
ingestUrl: string;
|
|
72
104
|
ingestKey: string;
|
|
105
|
+
/**
|
|
106
|
+
* The site's running experiments (pass the content client's
|
|
107
|
+
* `getRunningExperiments`). With it, each event's A/B arm is resolved here
|
|
108
|
+
* from the `wmm_vid` cookie; without it, events carry no arm. The browser's
|
|
109
|
+
* own `variant` is discarded either way.
|
|
110
|
+
*/
|
|
111
|
+
getRunningExperiments?: () => Promise<RunningExperiment[]>;
|
|
73
112
|
}): (request: Request) => Promise<Response> {
|
|
74
113
|
return async function POST(request: Request): Promise<Response> {
|
|
75
114
|
const url = config.ingestUrl || DEFAULT_INGEST_URL;
|
|
@@ -77,6 +116,7 @@ export function createCollectHandler(config: {
|
|
|
77
116
|
// The token is the only thing that genuinely has to be configured per site.
|
|
78
117
|
if (!token) return noContent();
|
|
79
118
|
warnOnceAboutKeyShape(token);
|
|
119
|
+
if (!isSameOrigin(request)) return noContent();
|
|
80
120
|
|
|
81
121
|
let events: unknown[];
|
|
82
122
|
try {
|
|
@@ -93,13 +133,39 @@ export function createCollectHandler(config: {
|
|
|
93
133
|
return noContent();
|
|
94
134
|
}
|
|
95
135
|
|
|
136
|
+
// The A/B arm is decided here, from the visitor cookie and the experiment list
|
|
137
|
+
// the page render used — never taken from the payload. A posted `variant` could
|
|
138
|
+
// otherwise put any visitor into any arm and move an experiment's result. No
|
|
139
|
+
// cookie means the arm is unknowable, so none is sent.
|
|
140
|
+
const visitorId = readCookie(request, VISITOR_COOKIE);
|
|
141
|
+
const experiments: RunningExperiment[] =
|
|
142
|
+
visitorId && config.getRunningExperiments
|
|
143
|
+
? await config.getRunningExperiments().catch(() => [])
|
|
144
|
+
: [];
|
|
145
|
+
|
|
96
146
|
// The site key is stamped here, never trusted from the client: a browser must
|
|
97
147
|
// not be able to write events into another site's namespace by editing a
|
|
98
148
|
// payload. Anything the client sent under that key is overwritten.
|
|
99
|
-
const stamped = events
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
149
|
+
const stamped = events
|
|
150
|
+
.filter(
|
|
151
|
+
(e): e is Record<string, unknown> =>
|
|
152
|
+
!!e &&
|
|
153
|
+
typeof e === "object" &&
|
|
154
|
+
typeof (e as { path?: unknown }).path === "string" &&
|
|
155
|
+
(e as { path: string }).path.startsWith("/"),
|
|
156
|
+
)
|
|
157
|
+
.map((e) => {
|
|
158
|
+
const { variant: _clientVariant, ...rest } = e;
|
|
159
|
+
const path = (rest.path as string).split("?")[0];
|
|
160
|
+
const arm = visitorId ? resolveVariant(path, visitorId, experiments) : null;
|
|
161
|
+
return {
|
|
162
|
+
...rest,
|
|
163
|
+
...(visitorId ? { visitorId } : {}),
|
|
164
|
+
...(arm?.experimentKey ? { variant: arm.variant } : {}),
|
|
165
|
+
siteKey: config.siteKey,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
if (stamped.length === 0) return noContent();
|
|
103
169
|
|
|
104
170
|
try {
|
|
105
171
|
const res = await fetch(url, {
|
package/src/analytics/index.ts
CHANGED
package/src/attribution/index.ts
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site brand — the theme boundary (Studio spec 2026-10-08, phase 5).
|
|
3
|
+
*
|
|
4
|
+
* A client site declares its brand in CODE, in its root layout:
|
|
5
|
+
*
|
|
6
|
+
* const brand = defineSiteBrand({ theme: "acme", style: "flat" });
|
|
7
|
+
* <html lang="en" {...brandRootAttributes(brand)}>
|
|
8
|
+
*
|
|
9
|
+
* and passes the same `brand` to `createManifestHandler`, so Studio records it.
|
|
10
|
+
*
|
|
11
|
+
* FAIL CLOSED. With no `data-theme`, @web-my-money/tokens falls back to WMM's
|
|
12
|
+
* palette (CSS) and to "dark" (JS). A client site must never get there by
|
|
13
|
+
* omission, so every invalid input THROWS — and because this runs while the root
|
|
14
|
+
* layout renders, a throw is a failed build, not a quietly wrong page.
|
|
15
|
+
*
|
|
16
|
+
* Pure on purpose: no `server-only`, no Next imports, safe in any component.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const PACKAGE_VERSION = "2.2.0";
|
|
20
|
+
|
|
21
|
+
/** Theme ids owned by WMM in the tokens package (themes.json `owner: "wmm"`). */
|
|
22
|
+
export const WMM_THEMES = ["wmm", "site", "light", "dark"] as const;
|
|
23
|
+
|
|
24
|
+
const STYLES = ["flat", "glass"] as const;
|
|
25
|
+
const THEME_ID = /^[a-z][a-z0-9-]{0,40}$/;
|
|
26
|
+
|
|
27
|
+
export type SiteBrand = {
|
|
28
|
+
readonly theme: string;
|
|
29
|
+
readonly style: (typeof STYLES)[number];
|
|
30
|
+
readonly wmmSite: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function defineSiteBrand(input: {
|
|
34
|
+
theme: string;
|
|
35
|
+
style: (typeof STYLES)[number];
|
|
36
|
+
wmmSite?: boolean;
|
|
37
|
+
}): SiteBrand {
|
|
38
|
+
const { theme, style } = input;
|
|
39
|
+
const wmmSite = input.wmmSite === true;
|
|
40
|
+
|
|
41
|
+
// Refused rather than trimmed/lowercased: normalising here would make the id
|
|
42
|
+
// in the code and the id in the stylesheet two different strings.
|
|
43
|
+
if (typeof theme !== "string" || !THEME_ID.test(theme)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`defineSiteBrand: theme ${JSON.stringify(theme)} is not a valid theme id ` +
|
|
46
|
+
"(lowercase letters, digits and dashes, starting with a letter).",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if ((WMM_THEMES as readonly string[]).includes(theme) && !wmmSite) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`defineSiteBrand: "${theme}" is WMM's own theme. A client site needs its own ` +
|
|
52
|
+
"theme from the design system (gen-client-theme). Only a WMM site may pass wmmSite: true.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (!(STYLES as readonly string[]).includes(style)) {
|
|
56
|
+
throw new Error(`defineSiteBrand: style must be "flat" or "glass", got ${JSON.stringify(style)}.`);
|
|
57
|
+
}
|
|
58
|
+
return Object.freeze({ theme, style, wmmSite });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function brandRootAttributes(brand: SiteBrand): {
|
|
62
|
+
"data-theme": string;
|
|
63
|
+
"data-style": string;
|
|
64
|
+
} {
|
|
65
|
+
return { "data-theme": brand.theme, "data-style": brand.style };
|
|
66
|
+
}
|
package/src/content/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import type { SiteBrand } from "../brand/index";
|
|
2
3
|
import type { ContentManifest } from "./payload";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -22,13 +23,31 @@ import type { ContentManifest } from "./payload";
|
|
|
22
23
|
*/
|
|
23
24
|
export function createManifestHandler(
|
|
24
25
|
getManifest: () => Promise<ContentManifest>,
|
|
26
|
+
/**
|
|
27
|
+
* REQUIRED (2.0.0). The site's brand, from `defineSiteBrand`. Written into the
|
|
28
|
+
* manifest so Studio records which theme this site ships — a site cannot
|
|
29
|
+
* publish a manifest without declaring one. Spread LAST so nothing in the
|
|
30
|
+
* app's own manifest can override it.
|
|
31
|
+
*/
|
|
32
|
+
brand: SiteBrand,
|
|
25
33
|
): () => Promise<Response> {
|
|
34
|
+
// TypeScript requires the brand; a JS caller or a cast can still skip it. Fail
|
|
35
|
+
// where the mistake is (the site's build), not at Studio's sync. Frozen is the
|
|
36
|
+
// mark of defineSiteBrand, which validated it.
|
|
37
|
+
if (!brand || !Object.isFrozen(brand)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"createManifestHandler: pass the site's brand from defineSiteBrand(...) as the second argument.",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
26
42
|
return async function GET() {
|
|
27
43
|
const manifest = await getManifest();
|
|
28
|
-
return NextResponse.json(
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
return NextResponse.json(
|
|
45
|
+
{ ...manifest, brand: { theme: brand.theme, style: brand.style, wmmSite: brand.wmmSite } },
|
|
46
|
+
{
|
|
47
|
+
headers: {
|
|
48
|
+
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
|
|
49
|
+
},
|
|
31
50
|
},
|
|
32
|
-
|
|
51
|
+
);
|
|
33
52
|
};
|
|
34
53
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Pure; safe in client components. */
|
|
2
|
+
export const PACKAGE_VERSION = "2.2.0";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CSS `object-position` for a Studio image value's focal point (spec §6.2).
|
|
6
|
+
* Anything missing or out of range reads as the centre — the browser default —
|
|
7
|
+
* so an image without a focal point renders exactly as before.
|
|
8
|
+
*/
|
|
9
|
+
export function focalObjectPosition(value: unknown): string {
|
|
10
|
+
const f = (value as { focal?: { x?: unknown; y?: unknown } } | null | undefined)?.focal;
|
|
11
|
+
const ok = (n: unknown): n is number => typeof n === "number" && n >= 0 && n <= 1;
|
|
12
|
+
if (!f || !ok(f.x) || !ok(f.y)) return "50% 50%";
|
|
13
|
+
return `${Math.round(f.x * 100)}% ${Math.round(f.y * 100)}%`;
|
|
14
|
+
}
|