@web-my-money/studio-consumer 2.4.0 → 2.4.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.
- package/README.md +123 -111
- package/package.json +33 -33
- package/skills/onboard-site/SKILL.md +125 -125
- package/src/analytics/collector.ts +49 -9
- package/src/analytics/index.ts +37 -37
- package/src/analytics/proxy.ts +10 -2
- package/src/attribution/index.ts +14 -14
- package/src/brand/index.ts +66 -66
- package/src/content/index.ts +27 -27
- package/src/image/index.ts +14 -14
package/README.md
CHANGED
|
@@ -1,111 +1,123 @@
|
|
|
1
|
-
# @web-my-money/studio-consumer
|
|
2
|
-
|
|
3
|
-
Consumer-side integration for WMM Studio: content, analytics, attribution.
|
|
4
|
-
|
|
5
|
-
This is a source-only workspace package — no build step, no `dist`. Consumers
|
|
6
|
-
are Next.js apps that already transpile workspace and scoped packages, so
|
|
7
|
-
publishing compiled output here would only add a compile-and-publish cycle
|
|
8
|
-
for no gain.
|
|
9
|
-
|
|
10
|
-
## Connect a site: one command (2.3.0; new sites 2.4.0)
|
|
11
|
-
|
|
12
|
-
In Studio, open the site, Site settings, **Get the setup command**, then in the
|
|
13
|
-
app's folder (or, for a brand-new site, in an empty folder):
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npx @web-my-money/studio-consumer init <code>
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
It checks the machine, installs this package, writes the routes, wraps
|
|
20
|
-
`next.config` with `withStudio`, links Vercel and sets the env vars, opens Claude
|
|
21
|
-
Code (the `onboard-site` skill shipped here) to wire the layout and choose the
|
|
22
|
-
editable text, verifies, opens a pull request, waits for the deploy and asks
|
|
23
|
-
Studio to sync. Rerun it after any stop; it resumes. `init --explain` shows the
|
|
24
|
-
plan without changing anything.
|
|
25
|
-
|
|
26
|
-
In an empty folder (2.4.0) it first creates the client's private repository from
|
|
27
|
-
`Web-My-Money/wmm-site-template`, pulls it in and puts the site's key and name in
|
|
28
|
-
place of the template's markers; the Claude step then sets the client's colours
|
|
29
|
-
and first copy instead of wiring a layout.
|
|
30
|
-
|
|
31
|
-
## Entry points
|
|
32
|
-
|
|
33
|
-
- `@web-my-money/studio-consumer/analytics`
|
|
34
|
-
- `@web-my-money/studio-consumer/content`
|
|
35
|
-
- `@web-my-money/studio-consumer/attribution`
|
|
36
|
-
- `@web-my-money/studio-consumer/headers`
|
|
37
|
-
- `@web-my-money/studio-consumer/brand` (2.0.0)
|
|
38
|
-
- `@web-my-money/studio-consumer/image` (2.1.0)
|
|
39
|
-
- `@web-my-money/studio-consumer/next` (2.3.0): `withStudio(nextConfig)`
|
|
40
|
-
|
|
41
|
-
Each entry point exports `PACKAGE_VERSION`, to prove at runtime that a consumer
|
|
42
|
-
is resolving this package rather than a stale copy.
|
|
43
|
-
|
|
44
|
-
## Brand (2.0.0, breaking)
|
|
45
|
-
|
|
46
|
-
A site declares its brand theme in code. Every invalid input throws, and this
|
|
47
|
-
runs while the root layout renders, so a site with no brand, a malformed theme
|
|
48
|
-
id, or WMM's own theme fails its build instead of silently inheriting WMM's
|
|
49
|
-
palette.
|
|
50
|
-
|
|
51
|
-
```ts
|
|
52
|
-
// lib/brand.ts — its own module: Next refuses unknown named exports from a layout file
|
|
53
|
-
import { defineSiteBrand } from "@web-my-money/studio-consumer/brand";
|
|
54
|
-
|
|
55
|
-
export const brand = defineSiteBrand({ theme: "acme", style: "flat" });
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
```tsx
|
|
59
|
-
// app/layout.tsx
|
|
60
|
-
import { brandRootAttributes } from "@web-my-money/studio-consumer/brand";
|
|
61
|
-
import { brand } from "@/lib/brand";
|
|
62
|
-
|
|
63
|
-
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
64
|
-
return <html lang="en" {...brandRootAttributes(brand)}><body>{children}</body></html>;
|
|
65
|
-
}
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
```ts
|
|
69
|
-
// app/api/content/manifest/route.ts
|
|
70
|
-
import { brand } from "@/lib/brand";
|
|
71
|
-
|
|
72
|
-
export const GET = createManifestHandler(getManifest, brand);
|
|
73
|
-
// No `export const dynamic` (2.3.0+): the handler waits for a real request, so the
|
|
74
|
-
// manifest is always live, and cacheComponents apps refuse that export anyway.
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
`createManifestHandler` now REQUIRES the brand (the breaking change in 2.0.0), so
|
|
78
|
-
Studio records the theme the site ships. Only a WMM site may use a WMM theme
|
|
79
|
-
(`wmm`, `site`, `light`, `dark`), by passing `wmmSite: true`.
|
|
80
|
-
|
|
81
|
-
## Image focal point (2.1.0)
|
|
82
|
-
|
|
83
|
-
An image value edited in Studio can carry `focal: { x, y }` (0–1 from the
|
|
84
|
-
top-left): where the subject is. Apply it so responsive crops keep the subject
|
|
85
|
-
in frame. No focal point, or a malformed one, reads as the centre, which is the
|
|
86
|
-
browser default, so existing images render exactly as before.
|
|
87
|
-
|
|
88
|
-
```tsx
|
|
89
|
-
import { focalObjectPosition } from "@web-my-money/studio-consumer/image";
|
|
90
|
-
|
|
91
|
-
<img src={value.url} alt={value.alt.en} style={{ objectFit: "cover", objectPosition: focalObjectPosition(value) }} />
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## Analytics collector (2.2.0)
|
|
95
|
-
|
|
96
|
-
`createCollectHandler` only accepts posts from the site's own pages (`Sec-Fetch-Site`,
|
|
97
|
-
falling back to `Origin`), and it decides each event's A/B arm on the server from the
|
|
98
|
-
`wmm_vid` cookie. The browser's own `variant` is always discarded. Pass the content
|
|
99
|
-
client's `getRunningExperiments` so the arm can be resolved:
|
|
100
|
-
|
|
101
|
-
```ts
|
|
102
|
-
export const POST = createCollectHandler({
|
|
103
|
-
siteKey: SITE_KEY,
|
|
104
|
-
ingestUrl: process.env.FUNNEL_INGEST_URL ?? "",
|
|
105
|
-
ingestKey: process.env.FUNNEL_INGEST_TOKEN ?? "",
|
|
106
|
-
getRunningExperiments: () => content.getRunningExperiments(),
|
|
107
|
-
});
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
Without it, events carry no arm and the site stays out of any A/B comparison.
|
|
111
|
-
|
|
1
|
+
# @web-my-money/studio-consumer
|
|
2
|
+
|
|
3
|
+
Consumer-side integration for WMM Studio: content, analytics, attribution.
|
|
4
|
+
|
|
5
|
+
This is a source-only workspace package — no build step, no `dist`. Consumers
|
|
6
|
+
are Next.js apps that already transpile workspace and scoped packages, so
|
|
7
|
+
publishing compiled output here would only add a compile-and-publish cycle
|
|
8
|
+
for no gain.
|
|
9
|
+
|
|
10
|
+
## Connect a site: one command (2.3.0; new sites 2.4.0)
|
|
11
|
+
|
|
12
|
+
In Studio, open the site, Site settings, **Get the setup command**, then in the
|
|
13
|
+
app's folder (or, for a brand-new site, in an empty folder):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @web-my-money/studio-consumer init <code>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It checks the machine, installs this package, writes the routes, wraps
|
|
20
|
+
`next.config` with `withStudio`, links Vercel and sets the env vars, opens Claude
|
|
21
|
+
Code (the `onboard-site` skill shipped here) to wire the layout and choose the
|
|
22
|
+
editable text, verifies, opens a pull request, waits for the deploy and asks
|
|
23
|
+
Studio to sync. Rerun it after any stop; it resumes. `init --explain` shows the
|
|
24
|
+
plan without changing anything.
|
|
25
|
+
|
|
26
|
+
In an empty folder (2.4.0) it first creates the client's private repository from
|
|
27
|
+
`Web-My-Money/wmm-site-template`, pulls it in and puts the site's key and name in
|
|
28
|
+
place of the template's markers; the Claude step then sets the client's colours
|
|
29
|
+
and first copy instead of wiring a layout.
|
|
30
|
+
|
|
31
|
+
## Entry points
|
|
32
|
+
|
|
33
|
+
- `@web-my-money/studio-consumer/analytics`
|
|
34
|
+
- `@web-my-money/studio-consumer/content`
|
|
35
|
+
- `@web-my-money/studio-consumer/attribution`
|
|
36
|
+
- `@web-my-money/studio-consumer/headers`
|
|
37
|
+
- `@web-my-money/studio-consumer/brand` (2.0.0)
|
|
38
|
+
- `@web-my-money/studio-consumer/image` (2.1.0)
|
|
39
|
+
- `@web-my-money/studio-consumer/next` (2.3.0): `withStudio(nextConfig)`
|
|
40
|
+
|
|
41
|
+
Each entry point exports `PACKAGE_VERSION`, to prove at runtime that a consumer
|
|
42
|
+
is resolving this package rather than a stale copy.
|
|
43
|
+
|
|
44
|
+
## Brand (2.0.0, breaking)
|
|
45
|
+
|
|
46
|
+
A site declares its brand theme in code. Every invalid input throws, and this
|
|
47
|
+
runs while the root layout renders, so a site with no brand, a malformed theme
|
|
48
|
+
id, or WMM's own theme fails its build instead of silently inheriting WMM's
|
|
49
|
+
palette.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// lib/brand.ts — its own module: Next refuses unknown named exports from a layout file
|
|
53
|
+
import { defineSiteBrand } from "@web-my-money/studio-consumer/brand";
|
|
54
|
+
|
|
55
|
+
export const brand = defineSiteBrand({ theme: "acme", style: "flat" });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
// app/layout.tsx
|
|
60
|
+
import { brandRootAttributes } from "@web-my-money/studio-consumer/brand";
|
|
61
|
+
import { brand } from "@/lib/brand";
|
|
62
|
+
|
|
63
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
64
|
+
return <html lang="en" {...brandRootAttributes(brand)}><body>{children}</body></html>;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// app/api/content/manifest/route.ts
|
|
70
|
+
import { brand } from "@/lib/brand";
|
|
71
|
+
|
|
72
|
+
export const GET = createManifestHandler(getManifest, brand);
|
|
73
|
+
// No `export const dynamic` (2.3.0+): the handler waits for a real request, so the
|
|
74
|
+
// manifest is always live, and cacheComponents apps refuse that export anyway.
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`createManifestHandler` now REQUIRES the brand (the breaking change in 2.0.0), so
|
|
78
|
+
Studio records the theme the site ships. Only a WMM site may use a WMM theme
|
|
79
|
+
(`wmm`, `site`, `light`, `dark`), by passing `wmmSite: true`.
|
|
80
|
+
|
|
81
|
+
## Image focal point (2.1.0)
|
|
82
|
+
|
|
83
|
+
An image value edited in Studio can carry `focal: { x, y }` (0–1 from the
|
|
84
|
+
top-left): where the subject is. Apply it so responsive crops keep the subject
|
|
85
|
+
in frame. No focal point, or a malformed one, reads as the centre, which is the
|
|
86
|
+
browser default, so existing images render exactly as before.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { focalObjectPosition } from "@web-my-money/studio-consumer/image";
|
|
90
|
+
|
|
91
|
+
<img src={value.url} alt={value.alt.en} style={{ objectFit: "cover", objectPosition: focalObjectPosition(value) }} />
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Analytics collector (2.2.0)
|
|
95
|
+
|
|
96
|
+
`createCollectHandler` only accepts posts from the site's own pages (`Sec-Fetch-Site`,
|
|
97
|
+
falling back to `Origin`), and it decides each event's A/B arm on the server from the
|
|
98
|
+
`wmm_vid` cookie. The browser's own `variant` is always discarded. Pass the content
|
|
99
|
+
client's `getRunningExperiments` so the arm can be resolved:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
export const POST = createCollectHandler({
|
|
103
|
+
siteKey: SITE_KEY,
|
|
104
|
+
ingestUrl: process.env.FUNNEL_INGEST_URL ?? "",
|
|
105
|
+
ingestKey: process.env.FUNNEL_INGEST_TOKEN ?? "",
|
|
106
|
+
getRunningExperiments: () => content.getRunningExperiments(),
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Without it, events carry no arm and the site stays out of any A/B comparison.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
## Visitor identity (2.4.1)
|
|
114
|
+
|
|
115
|
+
Two fixes taken in from WMM Website, where they had been made after the package was split out:
|
|
116
|
+
|
|
117
|
+
- The browser collector writes the `wmm_vid` cookie itself when the page arrived without one, so a
|
|
118
|
+
site may set the cookie in its proxy only on routes that run A/B tests (keeping every other page
|
|
119
|
+
CDN-cacheable) and a visitor still keeps one identity across the site. Arms are labelled only from
|
|
120
|
+
the cookie the page arrived with, never from one the browser just wrote.
|
|
121
|
+
- `withVisitorCookie` puts a new visitor's id on the request as well as the response, so their very
|
|
122
|
+
first render is bucketed. Before, a first visit rendered control while its events carried the new
|
|
123
|
+
id's arm.
|
package/package.json
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@web-my-money/studio-consumer",
|
|
3
|
-
"version": "2.4.
|
|
4
|
-
"description": "Consumer-side integration for WMM Studio: content, analytics, attribution.",
|
|
5
|
-
"license": "UNLICENSED",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/Web-My-Money/wmm-studio.git",
|
|
9
|
-
"directory": "packages/studio-consumer"
|
|
10
|
-
},
|
|
11
|
-
"type": "module",
|
|
12
|
-
"sideEffects": false,
|
|
13
|
-
"publishConfig": {
|
|
14
|
-
"access": "public"
|
|
15
|
-
},
|
|
16
|
-
"exports": {
|
|
17
|
-
"./analytics": "./src/analytics/index.ts",
|
|
18
|
-
"./content": "./src/content/index.ts",
|
|
19
|
-
"./attribution": "./src/attribution/index.ts",
|
|
20
|
-
"./headers": "./src/content/headers.mjs",
|
|
21
|
-
"./brand": "./src/brand/index.ts",
|
|
22
|
-
"./image": "./src/image/index.ts",
|
|
23
|
-
"./next": "./src/next/index.mjs"
|
|
24
|
-
},
|
|
25
|
-
"peerDependencies": {
|
|
26
|
-
"next": ">=16",
|
|
27
|
-
"react": ">=19"
|
|
28
|
-
},
|
|
29
|
-
"bin": {
|
|
30
|
-
"studio-consumer": "./bin/studio-consumer.mjs"
|
|
31
|
-
},
|
|
32
|
-
"files": ["src", "bin", "cli", "skills"]
|
|
33
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@web-my-money/studio-consumer",
|
|
3
|
+
"version": "2.4.1",
|
|
4
|
+
"description": "Consumer-side integration for WMM Studio: content, analytics, attribution.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Web-My-Money/wmm-studio.git",
|
|
9
|
+
"directory": "packages/studio-consumer"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
"./analytics": "./src/analytics/index.ts",
|
|
18
|
+
"./content": "./src/content/index.ts",
|
|
19
|
+
"./attribution": "./src/attribution/index.ts",
|
|
20
|
+
"./headers": "./src/content/headers.mjs",
|
|
21
|
+
"./brand": "./src/brand/index.ts",
|
|
22
|
+
"./image": "./src/image/index.ts",
|
|
23
|
+
"./next": "./src/next/index.mjs"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"next": ">=16",
|
|
27
|
+
"react": ">=19"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"studio-consumer": "./bin/studio-consumer.mjs"
|
|
31
|
+
},
|
|
32
|
+
"files": ["src", "bin", "cli", "skills"]
|
|
33
|
+
}
|
|
@@ -1,125 +1,125 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: onboard-site
|
|
3
|
-
description: Finish connecting this Next.js app to WMM Studio after `npx @web-my-money/studio-consumer init` has run. Wires the root layout and the proxy/middleware, then proposes which text should be editable in Studio and writes the content manifest once the developer approves. For a new site made from WMM's template, sets the client's colours and first copy instead. Use when the init command opens Claude Code on /onboard-site.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Finish connecting this app to WMM Studio
|
|
7
|
-
|
|
8
|
-
The `init` command already did the mechanical part: it installed
|
|
9
|
-
`@web-my-money/studio-consumer`, wrapped `next.config` with `withStudio`, and wrote
|
|
10
|
-
`lib/studio.ts` (the site key), `lib/brand.ts`, `lib/content.ts`, a starter
|
|
11
|
-
`lib/content-manifest.ts` and the API routes under `app/api/`. (In a `src/` app all of
|
|
12
|
-
these live under `src/`.) Your job is the part that needs judgement on someone else's
|
|
13
|
-
code.
|
|
14
|
-
|
|
15
|
-
**The developer may never have seen Studio before.** Explain each change in one plain
|
|
16
|
-
sentence, show the diff, and wait for a yes before writing it. Never rewrite a file
|
|
17
|
-
wholesale; make the smallest edit that does the job. Never remove an existing redirect,
|
|
18
|
-
rewrite, header or script.
|
|
19
|
-
|
|
20
|
-
Read `.wmm-onboarding/state.json` first: it lists the modules this site uses
|
|
21
|
-
(`content`, `analytics`, `forms`, `attribution`, `ab`, …). Skip anything for a module
|
|
22
|
-
that is not listed.
|
|
23
|
-
|
|
24
|
-
## 0. A new site from WMM's template (`"mode": "new"` in state.json)
|
|
25
|
-
|
|
26
|
-
The site was just created from `Web-My-Money/wmm-site-template`, so sections 1 and 2 are
|
|
27
|
-
already done: the layout (`app/[lang]/layout.tsx`) has the brand attributes, the editor
|
|
28
|
-
overlay and analytics, and `proxy.ts` sets the visitor cookie. Check they are there, say so in
|
|
29
|
-
one line, and skip to the client's details. Read `CLAUDE.md` for the site's rules.
|
|
30
|
-
|
|
31
|
-
1. Ask the developer, one question at a time:
|
|
32
|
-
- the business name exactly as the client writes it, and one sentence on what they do and
|
|
33
|
-
for whom;
|
|
34
|
-
- the brand's primary and accent colours as hex codes (from the client's logo or brand
|
|
35
|
-
guide; "not yet" is a fine answer: leave the magenta placeholder and say so);
|
|
36
|
-
- which language visitors should land in by default (English or Spanish);
|
|
37
|
-
- where the main button should go (the client's booking calendar or form link), if known.
|
|
38
|
-
2. Then propose, as one diff per file, and write only after a yes:
|
|
39
|
-
- `app/client-theme.css`: the colours (`--primary`, `--accent`, and a readable
|
|
40
|
-
`--primary-foreground`: white on a dark primary, near-black on a light one);
|
|
41
|
-
- `dictionaries/en.json` and `es.json`: `meta`, `hero` and `services` from what the
|
|
42
|
-
developer told you, in both languages (Spanish neutral, no regional slang). Keep every
|
|
43
|
-
key; the two files must keep identical keys;
|
|
44
|
-
- `lib/i18n.ts`: `DEFAULT_LOCALE`, only if it changes;
|
|
45
|
-
- the main button's link in `app/[lang]/page.tsx` (the `TODO` on the closing banner).
|
|
46
|
-
3. **Never invent reviews, results, prices or credentials.** Leave the review placeholders
|
|
47
|
-
and the FAQ answers you do not know, and list them as "still needed from the client".
|
|
48
|
-
4. Section 3 below is a review here, not a rebuild: `lib/content-manifest.ts` already makes
|
|
49
|
-
the home page's marketing text editable. Show the list of slots, ask whether anything should
|
|
50
|
-
be added or removed, and change it only on a yes.
|
|
51
|
-
5. Go to section 4.
|
|
52
|
-
|
|
53
|
-
## 1. Root layout (`app/layout.tsx`, or the layout that renders `<html>`)
|
|
54
|
-
|
|
55
|
-
- **Brand (content):** `import { brandRootAttributes } from "@web-my-money/studio-consumer/brand";`
|
|
56
|
-
and `import { brand } from "<relative path>/lib/brand";`, then spread
|
|
57
|
-
`{...brandRootAttributes(brand)}` on `<html>`. Keep every attribute already there.
|
|
58
|
-
- **Click-to-edit (content):** render `<WmmEditOverlay studioOrigin={process.env.NEXT_PUBLIC_STUDIO_ORIGIN} />`
|
|
59
|
-
from `@web-my-money/studio-consumer/content` once, inside `<body>`.
|
|
60
|
-
- **Analytics (analytics):** render `<FunnelAnalytics />` and `<EngagementTracking />` from
|
|
61
|
-
`@web-my-money/studio-consumer/analytics` once, inside `<body>`.
|
|
62
|
-
- **Overrides (content):** wherever the app loads its dictionary for a locale (often a
|
|
63
|
-
`[lang]` layout or a `getDictionary` helper), pass it through
|
|
64
|
-
`await applyDictOverrides(dict, locale)` from `lib/content.ts` before rendering. This is
|
|
65
|
-
what makes an edit in Studio appear on the page; without it the checklist goes green
|
|
66
|
-
and edits never show. If the app has no dictionary, say so and use `getLocalizedSlot`
|
|
67
|
-
for each editable value in step 3 instead.
|
|
68
|
-
|
|
69
|
-
## 2. Proxy / middleware (analytics)
|
|
70
|
-
|
|
71
|
-
Studio's analytics and A/B testing need the `wmm_vid` visitor cookie, which
|
|
72
|
-
`withVisitorCookie` from `@web-my-money/studio-consumer/analytics` sets.
|
|
73
|
-
|
|
74
|
-
- Next 16 uses `proxy.ts` (older apps: `middleware.ts`), at the code root.
|
|
75
|
-
- **None exists:** create `proxy.ts` that returns `withVisitorCookie(request)`, with a
|
|
76
|
-
matcher that skips `_next`, `api` and static files.
|
|
77
|
-
- **One exists:** compose, never replace. Call `withVisitorCookie(request)` for the
|
|
78
|
-
pass-through case, and keep every existing redirect and rewrite exactly as it is. If the
|
|
79
|
-
existing code returns its own `NextResponse`, show the developer the two options (set
|
|
80
|
-
the cookie on that response, or call `withVisitorCookie` first) and let them choose.
|
|
81
|
-
|
|
82
|
-
## 3. Choose the editable text (content)
|
|
83
|
-
|
|
84
|
-
This is the real decision in the whole onboarding. **Do not decide it alone.**
|
|
85
|
-
|
|
86
|
-
1. Find the copy: the per-locale dictionary (e.g. `dictionaries/en.json`, `es.json`), or,
|
|
87
|
-
if there is none, the text in the page components.
|
|
88
|
-
2. Propose a table grouped by page and section, one row per candidate:
|
|
89
|
-
|
|
90
|
-
| Key | Current text (EN) | Editable? | Why |
|
|
91
|
-
|---|---|---|---|
|
|
92
|
-
|
|
93
|
-
Keys are `page.section.thing` (e.g. `home.hero.headline`). Give a one-line reason for
|
|
94
|
-
**every "no"**. Lean towards "no" for, and always justify:
|
|
95
|
-
- legal and compliance text (privacy, terms, disclaimers, medical or financial claims);
|
|
96
|
-
- form field names, validation and error messages;
|
|
97
|
-
- analytics, tracking and pixel ids;
|
|
98
|
-
- icons and component references (never editable);
|
|
99
|
-
- anything the code uses as an identifier, a route or a CSS class.
|
|
100
|
-
Lean towards "yes" for headlines, sub-headlines, body copy, calls to action, testimonials
|
|
101
|
-
and images a marketer would change.
|
|
102
|
-
3. Wait for the developer to approve or edit the table.
|
|
103
|
-
4. Then fill in `lib/content-manifest.ts`, keeping its `getManifest()` shape and the
|
|
104
|
-
`siteKey: SITE_KEY` it returns:
|
|
105
|
-
- a slot only for text the code actually renders;
|
|
106
|
-
- `type`: `text` (or `richtext`, `image`, `list`, `select` where that fits);
|
|
107
|
-
- `dictPath` for dictionary text (the override is merged into the dictionary, no
|
|
108
|
-
component changes needed); no `dictPath` for images or other non-dictionary values,
|
|
109
|
-
which are read with `getSlot` and passed down as props;
|
|
110
|
-
- `default` is read from the dictionary (`en`/`es` imports), **never retyped**, so it
|
|
111
|
-
cannot go stale;
|
|
112
|
-
- `label` in plain words a client understands ("Home: hero headline");
|
|
113
|
-
- `group` per page section, `sortOrder` in reading order.
|
|
114
|
-
|
|
115
|
-
If `lib/content-manifest.ts` already had real slots before `init` ran, keep them and only
|
|
116
|
-
add what the developer approves.
|
|
117
|
-
|
|
118
|
-
## 4. Check, then hand back
|
|
119
|
-
|
|
120
|
-
Run `npm run verify` (or the app's equivalent: typecheck, lint, test, build). Fix what it
|
|
121
|
-
reports. If the app has no test runner and `tests/studio-frame-ancestors.test.ts` was not
|
|
122
|
-
written, offer to add Vitest and that test.
|
|
123
|
-
|
|
124
|
-
When everything passes, tell the developer in one sentence what changed, and that they can
|
|
125
|
-
close Claude Code (type `/exit`) so the `init` command continues with the next step.
|
|
1
|
+
---
|
|
2
|
+
name: onboard-site
|
|
3
|
+
description: Finish connecting this Next.js app to WMM Studio after `npx @web-my-money/studio-consumer init` has run. Wires the root layout and the proxy/middleware, then proposes which text should be editable in Studio and writes the content manifest once the developer approves. For a new site made from WMM's template, sets the client's colours and first copy instead. Use when the init command opens Claude Code on /onboard-site.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Finish connecting this app to WMM Studio
|
|
7
|
+
|
|
8
|
+
The `init` command already did the mechanical part: it installed
|
|
9
|
+
`@web-my-money/studio-consumer`, wrapped `next.config` with `withStudio`, and wrote
|
|
10
|
+
`lib/studio.ts` (the site key), `lib/brand.ts`, `lib/content.ts`, a starter
|
|
11
|
+
`lib/content-manifest.ts` and the API routes under `app/api/`. (In a `src/` app all of
|
|
12
|
+
these live under `src/`.) Your job is the part that needs judgement on someone else's
|
|
13
|
+
code.
|
|
14
|
+
|
|
15
|
+
**The developer may never have seen Studio before.** Explain each change in one plain
|
|
16
|
+
sentence, show the diff, and wait for a yes before writing it. Never rewrite a file
|
|
17
|
+
wholesale; make the smallest edit that does the job. Never remove an existing redirect,
|
|
18
|
+
rewrite, header or script.
|
|
19
|
+
|
|
20
|
+
Read `.wmm-onboarding/state.json` first: it lists the modules this site uses
|
|
21
|
+
(`content`, `analytics`, `forms`, `attribution`, `ab`, …). Skip anything for a module
|
|
22
|
+
that is not listed.
|
|
23
|
+
|
|
24
|
+
## 0. A new site from WMM's template (`"mode": "new"` in state.json)
|
|
25
|
+
|
|
26
|
+
The site was just created from `Web-My-Money/wmm-site-template`, so sections 1 and 2 are
|
|
27
|
+
already done: the layout (`app/[lang]/layout.tsx`) has the brand attributes, the editor
|
|
28
|
+
overlay and analytics, and `proxy.ts` sets the visitor cookie. Check they are there, say so in
|
|
29
|
+
one line, and skip to the client's details. Read `CLAUDE.md` for the site's rules.
|
|
30
|
+
|
|
31
|
+
1. Ask the developer, one question at a time:
|
|
32
|
+
- the business name exactly as the client writes it, and one sentence on what they do and
|
|
33
|
+
for whom;
|
|
34
|
+
- the brand's primary and accent colours as hex codes (from the client's logo or brand
|
|
35
|
+
guide; "not yet" is a fine answer: leave the magenta placeholder and say so);
|
|
36
|
+
- which language visitors should land in by default (English or Spanish);
|
|
37
|
+
- where the main button should go (the client's booking calendar or form link), if known.
|
|
38
|
+
2. Then propose, as one diff per file, and write only after a yes:
|
|
39
|
+
- `app/client-theme.css`: the colours (`--primary`, `--accent`, and a readable
|
|
40
|
+
`--primary-foreground`: white on a dark primary, near-black on a light one);
|
|
41
|
+
- `dictionaries/en.json` and `es.json`: `meta`, `hero` and `services` from what the
|
|
42
|
+
developer told you, in both languages (Spanish neutral, no regional slang). Keep every
|
|
43
|
+
key; the two files must keep identical keys;
|
|
44
|
+
- `lib/i18n.ts`: `DEFAULT_LOCALE`, only if it changes;
|
|
45
|
+
- the main button's link in `app/[lang]/page.tsx` (the `TODO` on the closing banner).
|
|
46
|
+
3. **Never invent reviews, results, prices or credentials.** Leave the review placeholders
|
|
47
|
+
and the FAQ answers you do not know, and list them as "still needed from the client".
|
|
48
|
+
4. Section 3 below is a review here, not a rebuild: `lib/content-manifest.ts` already makes
|
|
49
|
+
the home page's marketing text editable. Show the list of slots, ask whether anything should
|
|
50
|
+
be added or removed, and change it only on a yes.
|
|
51
|
+
5. Go to section 4.
|
|
52
|
+
|
|
53
|
+
## 1. Root layout (`app/layout.tsx`, or the layout that renders `<html>`)
|
|
54
|
+
|
|
55
|
+
- **Brand (content):** `import { brandRootAttributes } from "@web-my-money/studio-consumer/brand";`
|
|
56
|
+
and `import { brand } from "<relative path>/lib/brand";`, then spread
|
|
57
|
+
`{...brandRootAttributes(brand)}` on `<html>`. Keep every attribute already there.
|
|
58
|
+
- **Click-to-edit (content):** render `<WmmEditOverlay studioOrigin={process.env.NEXT_PUBLIC_STUDIO_ORIGIN} />`
|
|
59
|
+
from `@web-my-money/studio-consumer/content` once, inside `<body>`.
|
|
60
|
+
- **Analytics (analytics):** render `<FunnelAnalytics />` and `<EngagementTracking />` from
|
|
61
|
+
`@web-my-money/studio-consumer/analytics` once, inside `<body>`.
|
|
62
|
+
- **Overrides (content):** wherever the app loads its dictionary for a locale (often a
|
|
63
|
+
`[lang]` layout or a `getDictionary` helper), pass it through
|
|
64
|
+
`await applyDictOverrides(dict, locale)` from `lib/content.ts` before rendering. This is
|
|
65
|
+
what makes an edit in Studio appear on the page; without it the checklist goes green
|
|
66
|
+
and edits never show. If the app has no dictionary, say so and use `getLocalizedSlot`
|
|
67
|
+
for each editable value in step 3 instead.
|
|
68
|
+
|
|
69
|
+
## 2. Proxy / middleware (analytics)
|
|
70
|
+
|
|
71
|
+
Studio's analytics and A/B testing need the `wmm_vid` visitor cookie, which
|
|
72
|
+
`withVisitorCookie` from `@web-my-money/studio-consumer/analytics` sets.
|
|
73
|
+
|
|
74
|
+
- Next 16 uses `proxy.ts` (older apps: `middleware.ts`), at the code root.
|
|
75
|
+
- **None exists:** create `proxy.ts` that returns `withVisitorCookie(request)`, with a
|
|
76
|
+
matcher that skips `_next`, `api` and static files.
|
|
77
|
+
- **One exists:** compose, never replace. Call `withVisitorCookie(request)` for the
|
|
78
|
+
pass-through case, and keep every existing redirect and rewrite exactly as it is. If the
|
|
79
|
+
existing code returns its own `NextResponse`, show the developer the two options (set
|
|
80
|
+
the cookie on that response, or call `withVisitorCookie` first) and let them choose.
|
|
81
|
+
|
|
82
|
+
## 3. Choose the editable text (content)
|
|
83
|
+
|
|
84
|
+
This is the real decision in the whole onboarding. **Do not decide it alone.**
|
|
85
|
+
|
|
86
|
+
1. Find the copy: the per-locale dictionary (e.g. `dictionaries/en.json`, `es.json`), or,
|
|
87
|
+
if there is none, the text in the page components.
|
|
88
|
+
2. Propose a table grouped by page and section, one row per candidate:
|
|
89
|
+
|
|
90
|
+
| Key | Current text (EN) | Editable? | Why |
|
|
91
|
+
|---|---|---|---|
|
|
92
|
+
|
|
93
|
+
Keys are `page.section.thing` (e.g. `home.hero.headline`). Give a one-line reason for
|
|
94
|
+
**every "no"**. Lean towards "no" for, and always justify:
|
|
95
|
+
- legal and compliance text (privacy, terms, disclaimers, medical or financial claims);
|
|
96
|
+
- form field names, validation and error messages;
|
|
97
|
+
- analytics, tracking and pixel ids;
|
|
98
|
+
- icons and component references (never editable);
|
|
99
|
+
- anything the code uses as an identifier, a route or a CSS class.
|
|
100
|
+
Lean towards "yes" for headlines, sub-headlines, body copy, calls to action, testimonials
|
|
101
|
+
and images a marketer would change.
|
|
102
|
+
3. Wait for the developer to approve or edit the table.
|
|
103
|
+
4. Then fill in `lib/content-manifest.ts`, keeping its `getManifest()` shape and the
|
|
104
|
+
`siteKey: SITE_KEY` it returns:
|
|
105
|
+
- a slot only for text the code actually renders;
|
|
106
|
+
- `type`: `text` (or `richtext`, `image`, `list`, `select` where that fits);
|
|
107
|
+
- `dictPath` for dictionary text (the override is merged into the dictionary, no
|
|
108
|
+
component changes needed); no `dictPath` for images or other non-dictionary values,
|
|
109
|
+
which are read with `getSlot` and passed down as props;
|
|
110
|
+
- `default` is read from the dictionary (`en`/`es` imports), **never retyped**, so it
|
|
111
|
+
cannot go stale;
|
|
112
|
+
- `label` in plain words a client understands ("Home: hero headline");
|
|
113
|
+
- `group` per page section, `sortOrder` in reading order.
|
|
114
|
+
|
|
115
|
+
If `lib/content-manifest.ts` already had real slots before `init` ran, keep them and only
|
|
116
|
+
add what the developer approves.
|
|
117
|
+
|
|
118
|
+
## 4. Check, then hand back
|
|
119
|
+
|
|
120
|
+
Run `npm run verify` (or the app's equivalent: typecheck, lint, test, build). Fix what it
|
|
121
|
+
reports. If the app has no test runner and `tests/studio-frame-ancestors.test.ts` was not
|
|
122
|
+
written, offer to add Vitest and that test.
|
|
123
|
+
|
|
124
|
+
When everything passes, tell the developer in one sentence what changed, and that they can
|
|
125
|
+
close Claude Code (type `/exit`) so the `init` command continues with the next step.
|
|
@@ -75,7 +75,7 @@ function armFor(path: string): string | undefined {
|
|
|
75
75
|
try {
|
|
76
76
|
const { variant, experimentKey } = resolveVariant(
|
|
77
77
|
path,
|
|
78
|
-
|
|
78
|
+
serverSeenVisitorId(),
|
|
79
79
|
runningExperiments,
|
|
80
80
|
);
|
|
81
81
|
return experimentKey ? variant : undefined;
|
|
@@ -268,17 +268,57 @@ function cookieVisitorId(): string | null {
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Mirror the visitor id into the `wmm_vid` cookie.
|
|
273
|
+
*
|
|
274
|
+
* The proxy only mints this cookie on routes that can run an A/B test, so that
|
|
275
|
+
* the rest of the site stays cacheable. The collector still needs it everywhere
|
|
276
|
+
* — it stamps `visitorId` on an event from the cookie and from nothing else, and
|
|
277
|
+
* an event without one drops out of the comparison — and a visitor who lands on a
|
|
278
|
+
* static page first must keep the same identity when they later reach /med-spa,
|
|
279
|
+
* where the server reads the cookie. Writing it here, before the first event is
|
|
280
|
+
* built, covers both: the id is the one already in storage, so it never changes.
|
|
281
|
+
*
|
|
282
|
+
* Same attributes the proxy uses, for the same reasons: first-party, readable by
|
|
283
|
+
* the browser, no PII, the maximum lifetime Chrome honours.
|
|
284
|
+
*/
|
|
285
|
+
function writeVisitorCookie(id: string): void {
|
|
286
|
+
try {
|
|
287
|
+
document.cookie =
|
|
288
|
+
`wmm_vid=${encodeURIComponent(id)}; Max-Age=${400 * 24 * 60 * 60}; Path=/; SameSite=Lax; Secure`;
|
|
289
|
+
} catch {
|
|
290
|
+
// Cookies blocked: events fall back to the storage id on the client, which is
|
|
291
|
+
// the pre-existing behaviour for a visitor the middleware never reached.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The cookie as it arrived with this document — the identity the server could
|
|
297
|
+
* have bucketed this render with.
|
|
298
|
+
*
|
|
299
|
+
* Captured on first use and never refreshed, because this module now writes the
|
|
300
|
+
* cookie itself (see writeVisitorCookie). Reading it live would make the arm
|
|
301
|
+
* label depend on a cookie the server never saw: a page the proxy did not stamp
|
|
302
|
+
* would get one written here and then be labelled with an arm nobody assigned.
|
|
303
|
+
* The label must only ever come from an id that was on the request.
|
|
304
|
+
*/
|
|
305
|
+
let seenAtLoad: string | null | undefined;
|
|
306
|
+
function serverSeenVisitorId(): string | null {
|
|
307
|
+
if (seenAtLoad === undefined) seenAtLoad = cookieVisitorId();
|
|
308
|
+
return seenAtLoad;
|
|
309
|
+
}
|
|
310
|
+
|
|
271
311
|
function visitorId(): string {
|
|
272
|
-
const fromCookie =
|
|
312
|
+
const fromCookie = serverSeenVisitorId();
|
|
273
313
|
if (fromCookie) return fromCookie;
|
|
274
314
|
|
|
275
|
-
// No cookie:
|
|
276
|
-
// it
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return
|
|
315
|
+
// No cookie: this route is not one the proxy mints it on, or the visitor
|
|
316
|
+
// blocked it. Use the stored id, creating one on first sight, and publish it as
|
|
317
|
+
// the cookie so the server and the collector see the same identity.
|
|
318
|
+
const id = readStore(VISITOR_KEY) ?? uuid();
|
|
319
|
+
writeStore(VISITOR_KEY, id);
|
|
320
|
+
if (cookieVisitorId() !== id) writeVisitorCookie(id);
|
|
321
|
+
return id;
|
|
282
322
|
}
|
|
283
323
|
|
|
284
324
|
/**
|
package/src/analytics/index.ts
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
/** Proves at runtime which build a consumer is actually running. */
|
|
2
|
-
export const PACKAGE_VERSION = "2.4.
|
|
3
|
-
|
|
4
|
-
export {
|
|
5
|
-
resolveVariant,
|
|
6
|
-
bucketFor,
|
|
7
|
-
CONTROL,
|
|
8
|
-
VARIANT,
|
|
9
|
-
VISITOR_COOKIE,
|
|
10
|
-
type RunningExperiment,
|
|
11
|
-
} from "./bucketing";
|
|
12
|
-
|
|
13
|
-
export {
|
|
14
|
-
trackFunnelEvent,
|
|
15
|
-
registerExperiments,
|
|
16
|
-
flushFunnelEvents,
|
|
17
|
-
currentSessionId,
|
|
18
|
-
funnelAnalyticsEnabled,
|
|
19
|
-
consentGranted,
|
|
20
|
-
resetFunnelAnalytics,
|
|
21
|
-
type FunnelEventProps,
|
|
22
|
-
} from "./collector";
|
|
23
|
-
|
|
24
|
-
export { useFormAnalytics, type FormAnalytics, type FormAnalyticsOptions } from "./use-form-analytics";
|
|
25
|
-
|
|
26
|
-
export {
|
|
27
|
-
FunnelAnalytics,
|
|
28
|
-
EngagementTracking,
|
|
29
|
-
AbArm,
|
|
30
|
-
THRESHOLDS,
|
|
31
|
-
crossedThresholds,
|
|
32
|
-
labelFor,
|
|
33
|
-
} from "./components";
|
|
34
|
-
|
|
35
|
-
export { createCollectHandler } from "./collect-handler";
|
|
36
|
-
|
|
37
|
-
export { withVisitorCookie } from "./proxy";
|
|
1
|
+
/** Proves at runtime which build a consumer is actually running. */
|
|
2
|
+
export const PACKAGE_VERSION = "2.4.1";
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
resolveVariant,
|
|
6
|
+
bucketFor,
|
|
7
|
+
CONTROL,
|
|
8
|
+
VARIANT,
|
|
9
|
+
VISITOR_COOKIE,
|
|
10
|
+
type RunningExperiment,
|
|
11
|
+
} from "./bucketing";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
trackFunnelEvent,
|
|
15
|
+
registerExperiments,
|
|
16
|
+
flushFunnelEvents,
|
|
17
|
+
currentSessionId,
|
|
18
|
+
funnelAnalyticsEnabled,
|
|
19
|
+
consentGranted,
|
|
20
|
+
resetFunnelAnalytics,
|
|
21
|
+
type FunnelEventProps,
|
|
22
|
+
} from "./collector";
|
|
23
|
+
|
|
24
|
+
export { useFormAnalytics, type FormAnalytics, type FormAnalyticsOptions } from "./use-form-analytics";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
FunnelAnalytics,
|
|
28
|
+
EngagementTracking,
|
|
29
|
+
AbArm,
|
|
30
|
+
THRESHOLDS,
|
|
31
|
+
crossedThresholds,
|
|
32
|
+
labelFor,
|
|
33
|
+
} from "./components";
|
|
34
|
+
|
|
35
|
+
export { createCollectHandler } from "./collect-handler";
|
|
36
|
+
|
|
37
|
+
export { withVisitorCookie } from "./proxy";
|
package/src/analytics/proxy.ts
CHANGED
|
@@ -26,6 +26,14 @@ function newVisitorId(): string {
|
|
|
26
26
|
export function withVisitorCookie(req: NextRequest): NextResponse {
|
|
27
27
|
const { pathname } = req.nextUrl;
|
|
28
28
|
|
|
29
|
+
// A first-time visitor has no cookie yet. Mint the id here and put it on the
|
|
30
|
+
// REQUEST too, so this very render buckets them. Setting it only on the
|
|
31
|
+
// response rendered control on the first page view while the browser stamped
|
|
32
|
+
// the new id's arm on events, filing visitors who saw control under B.
|
|
33
|
+
const existingVisitorId = req.cookies.get(VISITOR_COOKIE)?.value;
|
|
34
|
+
const visitorId = existingVisitorId || newVisitorId();
|
|
35
|
+
if (!existingVisitorId) req.cookies.set(VISITOR_COOKIE, visitorId);
|
|
36
|
+
|
|
29
37
|
// The path, as a request header.
|
|
30
38
|
//
|
|
31
39
|
// Server components cannot read their own pathname, and A/B assignment has to
|
|
@@ -71,10 +79,10 @@ export function withVisitorCookie(req: NextRequest): NextResponse {
|
|
|
71
79
|
* Not httpOnly, on purpose: the collector reads it in the browser. It carries
|
|
72
80
|
* no PII — it is a random id, exactly like the localStorage value it replaces.
|
|
73
81
|
*/
|
|
74
|
-
if (!
|
|
82
|
+
if (!existingVisitorId) {
|
|
75
83
|
response.cookies.set({
|
|
76
84
|
name: VISITOR_COOKIE,
|
|
77
|
-
value:
|
|
85
|
+
value: visitorId,
|
|
78
86
|
maxAge: VISITOR_COOKIE_MAX_AGE,
|
|
79
87
|
path: "/",
|
|
80
88
|
sameSite: "lax",
|
package/src/attribution/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/** Proves at runtime which build a consumer is actually running. */
|
|
2
|
-
export const PACKAGE_VERSION = "2.4.
|
|
3
|
-
|
|
4
|
-
export {
|
|
5
|
-
getAttribution,
|
|
6
|
-
captureAttribution,
|
|
7
|
-
getAttributionPayload,
|
|
8
|
-
computeAttribution,
|
|
9
|
-
flattenAttribution,
|
|
10
|
-
hasCampaignSignal,
|
|
11
|
-
} from "./store";
|
|
12
|
-
export type { Attribution, Touch, AttributionPayload } from "./store";
|
|
13
|
-
|
|
14
|
-
export { sessionIdField, SESSION_ID_FIELD } from "./crm";
|
|
1
|
+
/** Proves at runtime which build a consumer is actually running. */
|
|
2
|
+
export const PACKAGE_VERSION = "2.4.1";
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
getAttribution,
|
|
6
|
+
captureAttribution,
|
|
7
|
+
getAttributionPayload,
|
|
8
|
+
computeAttribution,
|
|
9
|
+
flattenAttribution,
|
|
10
|
+
hasCampaignSignal,
|
|
11
|
+
} from "./store";
|
|
12
|
+
export type { Attribution, Touch, AttributionPayload } from "./store";
|
|
13
|
+
|
|
14
|
+
export { sessionIdField, SESSION_ID_FIELD } from "./crm";
|
package/src/brand/index.ts
CHANGED
|
@@ -1,66 +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.4.
|
|
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
|
-
}
|
|
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.4.1";
|
|
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,27 +1,27 @@
|
|
|
1
|
-
/** Proves at runtime which build a consumer is actually running. */
|
|
2
|
-
export const PACKAGE_VERSION = "2.4.
|
|
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";
|
|
1
|
+
/** Proves at runtime which build a consumer is actually running. */
|
|
2
|
+
export const PACKAGE_VERSION = "2.4.1";
|
|
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";
|
package/src/image/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/** Pure; safe in client components. */
|
|
2
|
-
export const PACKAGE_VERSION = "2.4.
|
|
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
|
-
}
|
|
1
|
+
/** Pure; safe in client components. */
|
|
2
|
+
export const PACKAGE_VERSION = "2.4.1";
|
|
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
|
+
}
|