@web-my-money/studio-consumer 2.3.0 → 2.4.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 +111 -106
- package/cli/claude-step.mjs +1 -1
- package/cli/init.mjs +55 -5
- package/cli/preflight.mjs +42 -17
- package/cli/provisioner.mjs +3 -2
- package/cli/scaffold.mjs +155 -0
- package/cli/state.mjs +13 -1
- package/package.json +1 -1
- package/skills/onboard-site/SKILL.md +30 -1
- package/src/analytics/index.ts +1 -1
- package/src/attribution/index.ts +1 -1
- package/src/brand/index.ts +1 -1
- package/src/content/index.ts +1 -1
- package/src/image/index.ts +1 -1
- package/src/next/index.d.mts +13 -13
package/README.md
CHANGED
|
@@ -1,106 +1,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)
|
|
11
|
-
|
|
12
|
-
In Studio, open the site, Site settings, **Get the setup command**, then in the
|
|
13
|
-
app's 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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
- `@web-my-money/studio-consumer/
|
|
34
|
-
- `@web-my-money/studio-consumer/
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// manifest
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
|
package/cli/claude-step.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Step
|
|
7
|
+
* Step 8 of `init`: the edits that need judgement on someone else's code (root
|
|
8
8
|
* layout, proxy/middleware, which text is editable) are done by Claude Code, on
|
|
9
9
|
* the developer's own subscription, through the `onboard-site` skill this
|
|
10
10
|
* package ships. Each edit is shown and approved before it is written.
|
package/cli/init.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { preflight } from "./preflight.mjs";
|
|
|
9
9
|
import { cliProvisioner } from "./provisioner.mjs";
|
|
10
10
|
import { writeSiteFiles, wrapNextConfig } from "./writers.mjs";
|
|
11
11
|
import { claudeStep } from "./claude-step.mjs";
|
|
12
|
+
import { personalise, pullTemplateRepo, templateKeyProblem } from "./scaffold.mjs";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* `npx @web-my-money/studio-consumer init <code>`: connect an existing Next.js
|
|
@@ -37,7 +38,7 @@ const POLL_TIMES = 60;
|
|
|
37
38
|
* cwd: string, ui: Ui, run: Runner, fetchImpl: Fetch, sleep: (ms: number) => Promise<void>,
|
|
38
39
|
* studioUrl: string, code: string | null, state: OnboardingState | null,
|
|
39
40
|
* provisioner: AnyProvisioner | null, provisionerScope?: string,
|
|
40
|
-
* injectedProvisioner: AnyProvisioner | null,
|
|
41
|
+
* injectedProvisioner: AnyProvisioner | null, mode?: "new" | "existing",
|
|
41
42
|
* }} Ctx
|
|
42
43
|
*/
|
|
43
44
|
|
|
@@ -48,7 +49,8 @@ const STEPS = [
|
|
|
48
49
|
title: "Check your setup",
|
|
49
50
|
why: "Makes sure Node, GitHub, Vercel and this app are ready before anything changes.",
|
|
50
51
|
async run(ctx) {
|
|
51
|
-
await preflight({ cwd: ctx.cwd, run: ctx.run, ui: ctx.ui, resuming: ctx.state !== null });
|
|
52
|
+
const r = await preflight({ cwd: ctx.cwd, run: ctx.run, ui: ctx.ui, resuming: ctx.state !== null });
|
|
53
|
+
ctx.mode = r.mode;
|
|
52
54
|
},
|
|
53
55
|
},
|
|
54
56
|
{
|
|
@@ -89,13 +91,61 @@ const STEPS = [
|
|
|
89
91
|
ctx.ui.info(`Connected to ${ctx.state.siteName} (${ctx.state.siteKey}). Modules: ${ctx.state.modules.join(", ")}.`);
|
|
90
92
|
},
|
|
91
93
|
},
|
|
94
|
+
{
|
|
95
|
+
id: "create",
|
|
96
|
+
title: "Create the site from WMM's template",
|
|
97
|
+
why: "For a new site: makes the client's own repository from WMM's site template and puts it in this folder.",
|
|
98
|
+
async run(ctx) {
|
|
99
|
+
const s = need(ctx);
|
|
100
|
+
// Saved state wins over this run's preflight: once the template is checked
|
|
101
|
+
// out the folder looks like an existing app, but it still needs finishing.
|
|
102
|
+
if (ctx.mode !== "new" && s.mode !== "new") {
|
|
103
|
+
return ctx.ui.info("This is an existing app, so there is nothing to create.");
|
|
104
|
+
}
|
|
105
|
+
const problem = templateKeyProblem(s.siteKey);
|
|
106
|
+
if (problem) ctx.ui.stop(problem);
|
|
107
|
+
const repo = `Web-My-Money/${s.siteKey}`;
|
|
108
|
+
if (!s.repoCreated) {
|
|
109
|
+
const ok = await ctx.ui.confirm(`Create the private repository ${repo} from WMM's site template, in this folder?`);
|
|
110
|
+
if (!ok) ctx.ui.stop("A new site needs its repository. Run this again when you are ready.");
|
|
111
|
+
try {
|
|
112
|
+
await provisioner(ctx).createRepoFromTemplate(s.siteKey);
|
|
113
|
+
ctx.ui.info(`Created ${repo}.`);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (!(e instanceof Error && /already exists/i.test(e.message))) throw e;
|
|
116
|
+
// Most often an earlier run created it and stopped before saving that.
|
|
117
|
+
const mine = await ctx.ui.confirm(
|
|
118
|
+
`${repo} already exists on GitHub. Is it this site's repository, made from WMM's template (for example by an earlier run of this command)? Pull it into this folder?`,
|
|
119
|
+
);
|
|
120
|
+
if (!mine) {
|
|
121
|
+
ctx.ui.stop(
|
|
122
|
+
`${repo} already exists, so this command will not create it again. If it is this site, clone it and run this command inside it.`,
|
|
123
|
+
`gh repo clone ${repo}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Remembered at once: a rerun after a later stop must neither create
|
|
128
|
+
// the repository twice nor forget that this is a new site.
|
|
129
|
+
s.repoCreated = true;
|
|
130
|
+
s.mode = "new";
|
|
131
|
+
saveState(ctx.cwd, s);
|
|
132
|
+
}
|
|
133
|
+
const pulled = await pullTemplateRepo({ cwd: ctx.cwd, run: ctx.run, sleep: ctx.sleep, name: s.siteKey });
|
|
134
|
+
if (!pulled.ok) ctx.ui.stop(pulled.message, pulled.nextCommand);
|
|
135
|
+
const changed = await personalise({ cwd: ctx.cwd, run: ctx.run, siteKey: s.siteKey, siteName: s.siteName });
|
|
136
|
+
// The template's colours are scoped to the site key, so it is the theme.
|
|
137
|
+
s.mode = "new";
|
|
138
|
+
s.theme = s.siteKey;
|
|
139
|
+
ctx.ui.info(`Made the template ${s.siteName}'s (${changed.length} files).`);
|
|
140
|
+
},
|
|
141
|
+
},
|
|
92
142
|
{
|
|
93
143
|
id: "install",
|
|
94
144
|
title: "Install the package",
|
|
95
145
|
why: "Adds WMM Studio's package to this app.",
|
|
96
146
|
async run(ctx) {
|
|
97
|
-
const r = await ctx.run("npm", ["install", "--install-links", "@web-my-money/studio-consumer@^2.
|
|
98
|
-
if (r.code !== 0) ctx.ui.stop("npm could not install the package.", "npm install --install-links @web-my-money/studio-consumer@^2.
|
|
147
|
+
const r = await ctx.run("npm", ["install", "--install-links", "@web-my-money/studio-consumer@^2.4"], { cwd: ctx.cwd });
|
|
148
|
+
if (r.code !== 0) ctx.ui.stop("npm could not install the package.", "npm install --install-links @web-my-money/studio-consumer@^2.4");
|
|
99
149
|
},
|
|
100
150
|
},
|
|
101
151
|
{
|
|
@@ -104,7 +154,7 @@ const STEPS = [
|
|
|
104
154
|
why: "Writes the small files Studio talks to, and lets Studio show this site inside its editor.",
|
|
105
155
|
async run(ctx) {
|
|
106
156
|
const s = need(ctx);
|
|
107
|
-
if (s.modules.includes("content")) {
|
|
157
|
+
if (s.modules.includes("content") && s.mode !== "new") {
|
|
108
158
|
s.theme = await ctx.ui.ask(
|
|
109
159
|
"Brand theme id for this site (the client's theme in WMM's design system)",
|
|
110
160
|
s.theme ?? s.siteKey,
|
package/cli/preflight.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { isOwnGitignore } from "./state.mjs";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Step 1 of the onboarding command: check the machine and the app BEFORE
|
|
@@ -11,6 +12,9 @@ import path from "node:path";
|
|
|
11
12
|
/** @typedef {import("./run.mjs").Runner} Runner */
|
|
12
13
|
/** @typedef {import("./ui.mjs").Ui} Ui */
|
|
13
14
|
|
|
15
|
+
/** Not content: git, a stopped run's progress, and files the operating system drops in folders. */
|
|
16
|
+
const IGNORED = new Set([".git", ".wmm-onboarding", ".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
17
|
+
|
|
14
18
|
/** @param {string} version */
|
|
15
19
|
function majorOf(version) {
|
|
16
20
|
const m = /(\d+)/.exec(version);
|
|
@@ -18,8 +22,11 @@ function majorOf(version) {
|
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
/**
|
|
25
|
+
* `new`: an empty folder, where the site is created from WMM's template.
|
|
26
|
+
* `existing`: a Next.js app, wired in place.
|
|
27
|
+
*
|
|
21
28
|
* @param {{ cwd: string, run: Runner, ui: Ui, resuming?: boolean }} ctx
|
|
22
|
-
* @returns {Promise<{ mode: "existing", nextMajor: number | null }>}
|
|
29
|
+
* @returns {Promise<{ mode: "new" | "existing", nextMajor: number | null }>}
|
|
23
30
|
*/
|
|
24
31
|
export async function preflight({ cwd, run, ui, resuming = false }) {
|
|
25
32
|
const node = majorOf(process.versions.node) ?? 0;
|
|
@@ -29,13 +36,16 @@ export async function preflight({ cwd, run, ui, resuming = false }) {
|
|
|
29
36
|
|
|
30
37
|
const pkgPath = path.join(cwd, "package.json");
|
|
31
38
|
if (!existsSync(pkgPath)) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
// A stopped first run leaves its progress folder behind; that is still empty.
|
|
40
|
+
const entries = readdirSync(cwd).filter(
|
|
41
|
+
(f) => !IGNORED.has(f) && !(f === ".gitignore" && isOwnGitignore(cwd)),
|
|
42
|
+
);
|
|
43
|
+
if (entries.length > 0) {
|
|
44
|
+
ui.stop("Run this inside the app's folder (the one that has package.json), or in an empty folder to start a new site.");
|
|
37
45
|
}
|
|
38
|
-
|
|
46
|
+
// A new site: no app and no repository yet, only the accounts that create them.
|
|
47
|
+
await signedIn(cwd, run, ui);
|
|
48
|
+
return { mode: "new", nextMajor: null };
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
@@ -49,13 +59,7 @@ export async function preflight({ cwd, run, ui, resuming = false }) {
|
|
|
49
59
|
);
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
if (gh.code === 127) ui.stop("The GitHub CLI is not installed.", "Install it from https://cli.github.com");
|
|
54
|
-
if (gh.code !== 0) ui.stop("You are not signed in to GitHub.", "gh auth login");
|
|
55
|
-
|
|
56
|
-
const vercel = await run("vercel", ["whoami"], { cwd });
|
|
57
|
-
if (vercel.code === 127) ui.stop("The Vercel CLI is not installed.", "npm install -g vercel");
|
|
58
|
-
if (vercel.code !== 0) ui.stop("You are not signed in to Vercel.", "vercel login");
|
|
62
|
+
await signedIn(cwd, run, ui);
|
|
59
63
|
|
|
60
64
|
const git = await run("git", ["status", "--porcelain"], { cwd });
|
|
61
65
|
if (git.code !== 0) ui.stop("This folder is not a git repository yet.", "git init");
|
|
@@ -67,12 +71,33 @@ export async function preflight({ cwd, run, ui, resuming = false }) {
|
|
|
67
71
|
);
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
return { mode: "existing", nextMajor };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* GitHub and Vercel sign-ins (the command acts through them), plus a warning
|
|
79
|
+
* when Claude Code is missing.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} cwd
|
|
82
|
+
* @param {Runner} run
|
|
83
|
+
* @param {Ui} ui
|
|
84
|
+
*/
|
|
85
|
+
async function signedIn(cwd, run, ui) {
|
|
86
|
+
const git = await run("git", ["--version"], { cwd });
|
|
87
|
+
if (git.code === 127) ui.stop("Git is not installed.", "Install it from https://git-scm.com");
|
|
88
|
+
|
|
89
|
+
const gh = await run("gh", ["auth", "status"], { cwd });
|
|
90
|
+
if (gh.code === 127) ui.stop("The GitHub CLI is not installed.", "Install it from https://cli.github.com");
|
|
91
|
+
if (gh.code !== 0) ui.stop("You are not signed in to GitHub.", "gh auth login");
|
|
92
|
+
|
|
93
|
+
const vercel = await run("vercel", ["whoami"], { cwd });
|
|
94
|
+
if (vercel.code === 127) ui.stop("The Vercel CLI is not installed.", "npm install -g vercel");
|
|
95
|
+
if (vercel.code !== 0) ui.stop("You are not signed in to Vercel.", "vercel login");
|
|
96
|
+
|
|
70
97
|
const claude = await run("claude", ["--version"], { cwd });
|
|
71
98
|
if (claude.code !== 0) {
|
|
72
99
|
ui.info(
|
|
73
|
-
"Heads up: Claude Code is not installed. You will need it
|
|
100
|
+
"Heads up: Claude Code is not installed. You will need it later, where it helps wire your layout and choose the editable text. Install: https://claude.com/claude-code",
|
|
74
101
|
);
|
|
75
102
|
}
|
|
76
|
-
|
|
77
|
-
return { mode: "existing", nextMajor };
|
|
78
103
|
}
|
package/cli/provisioner.mjs
CHANGED
|
@@ -48,14 +48,15 @@ export function cliProvisioner({ cwd, run, scope }) {
|
|
|
48
48
|
|
|
49
49
|
return {
|
|
50
50
|
async createRepoFromTemplate(name) {
|
|
51
|
+
// No --clone: it would clone into ./<name>, and this folder already holds
|
|
52
|
+
// the command's progress. The command pulls the repository in itself.
|
|
51
53
|
await must("gh", [
|
|
52
54
|
"repo",
|
|
53
55
|
"create",
|
|
54
56
|
`Web-My-Money/${name}`,
|
|
55
57
|
"--private",
|
|
56
58
|
"--template",
|
|
57
|
-
"Web-My-Money/wmm-
|
|
58
|
-
"--clone",
|
|
59
|
+
"Web-My-Money/wmm-site-template",
|
|
59
60
|
]);
|
|
60
61
|
},
|
|
61
62
|
async createRepoFromSource(name) {
|
package/cli/scaffold.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isOwnGitignore } from "./state.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A new site from WMM's template (spec 2026-10-09 §3.2): pull the repository
|
|
8
|
+
* GitHub created from `Web-My-Money/wmm-site-template` into the developer's
|
|
9
|
+
* folder, then make it this site's.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @typedef {import("./run.mjs").Runner} Runner */
|
|
13
|
+
|
|
14
|
+
/** The only two strings in the template that become this site's (see its README). */
|
|
15
|
+
export const MARKERS = { key: "wmm-template-site", name: "WMM Template Site" };
|
|
16
|
+
|
|
17
|
+
const FETCH_TRIES = 10;
|
|
18
|
+
const FETCH_WAIT_MS = 3000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* GitHub copies a template into the new repository in the background, so for a
|
|
22
|
+
* few seconds the repository has no `main`. That case is waited out; any other
|
|
23
|
+
* failure (usually git not signed in to GitHub) stops at once with the fix.
|
|
24
|
+
*
|
|
25
|
+
* @param {{ cwd: string, run: Runner, sleep: (ms: number) => Promise<void>, name: string }} ctx
|
|
26
|
+
* @returns {Promise<{ ok: true } | { ok: false, message: string, nextCommand?: string }>}
|
|
27
|
+
*/
|
|
28
|
+
export async function pullTemplateRepo({ cwd, run, sleep, name }) {
|
|
29
|
+
const url = `https://github.com/Web-My-Money/${name}.git`;
|
|
30
|
+
if (!existsSync(path.join(cwd, ".git"))) {
|
|
31
|
+
const init = await run("git", ["init", "-b", "main"], { cwd });
|
|
32
|
+
if (init.code !== 0) return { ok: false, message: `git could not start a repository here: ${lastLine(init)}` };
|
|
33
|
+
}
|
|
34
|
+
const origin = await run("git", ["remote", "get-url", "origin"], { cwd });
|
|
35
|
+
if (origin.code !== 0) {
|
|
36
|
+
const add = await run("git", ["remote", "add", "origin", url], { cwd });
|
|
37
|
+
if (add.code !== 0) return { ok: false, message: `git could not add the new repository: ${lastLine(add)}` };
|
|
38
|
+
} else {
|
|
39
|
+
// A folder already tied to some other repository would fetch the wrong one forever.
|
|
40
|
+
const current = origin.stdout.trim();
|
|
41
|
+
const ours = new RegExp(`github\\.com[/:]Web-My-Money/${name}(\\.git)?$`, "i");
|
|
42
|
+
if (!ours.test(current)) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
message: `This folder's git remote points at ${current}, not at Web-My-Money/${name}. Point it at the new site's repository, then run this again.`,
|
|
46
|
+
nextCommand: `git remote set-url origin ${url}`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < FETCH_TRIES; i++) {
|
|
52
|
+
const r = await run("git", ["fetch", "origin", "main"], { cwd });
|
|
53
|
+
if (r.code === 0) {
|
|
54
|
+
// The progress file's one-line .gitignore would block checking out the
|
|
55
|
+
// template's own (which already ignores the progress folder).
|
|
56
|
+
if (isOwnGitignore(cwd)) rmSync(path.join(cwd, ".gitignore"));
|
|
57
|
+
const co = await run("git", ["checkout", "-B", "main", "origin/main"], { cwd });
|
|
58
|
+
if (co.code !== 0) return { ok: false, message: `git could not check out the new site: ${lastLine(co)}` };
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
if (!/couldn't find remote ref/i.test(r.stderr)) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
message: `git could not download the new repository: ${lastLine(r)}. If git is not signed in to GitHub, this fixes it; then run this again.`,
|
|
65
|
+
nextCommand: "gh auth setup-git",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (i < FETCH_TRIES - 1) await sleep(FETCH_WAIT_MS);
|
|
69
|
+
}
|
|
70
|
+
return { ok: false, message: "GitHub is still preparing the repository. Run this again in a minute; it continues from here." };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The same rule and reserved ids as defineSiteBrand (src/brand/index.ts): in a
|
|
74
|
+
// template site the key IS the theme id, so it must be a valid client theme.
|
|
75
|
+
const THEME_ID = /^[a-z][a-z0-9-]{0,40}$/;
|
|
76
|
+
const WMM_THEMES = ["wmm", "site", "light", "dark"];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Why a site key cannot be used for a site made from the template, or null.
|
|
80
|
+
* Checked before anything is created, so a bad key never leaves a repository behind.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} siteKey
|
|
83
|
+
* @returns {string | null}
|
|
84
|
+
*/
|
|
85
|
+
export function templateKeyProblem(siteKey) {
|
|
86
|
+
if (THEME_ID.test(siteKey) && !WMM_THEMES.includes(siteKey)) return null;
|
|
87
|
+
return `The site key "${siteKey}" cannot be used for a site made from WMM's template: it becomes the brand theme id, so it must start with a letter, use only lowercase letters, digits and dashes, be at most 41 characters, and not be one of ${WMM_THEMES.join(", ")}. Add the site again in Studio with a key like that.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @param {{ stdout: string, stderr: string }} r */
|
|
91
|
+
function lastLine(r) {
|
|
92
|
+
return (r.stderr || r.stdout).trim().split(/\r?\n/).slice(-1)[0] ?? "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Replace the template's markers in every tracked file, escaped for where they
|
|
97
|
+
* land, and swap the template's README for one about this site.
|
|
98
|
+
*
|
|
99
|
+
* @param {{ cwd: string, run: Runner, siteKey: string, siteName: string }} ctx
|
|
100
|
+
* @returns {Promise<string[]>} the files that changed
|
|
101
|
+
*/
|
|
102
|
+
export async function personalise({ cwd, run, siteKey, siteName }) {
|
|
103
|
+
const problem = templateKeyProblem(siteKey);
|
|
104
|
+
if (problem) throw new Error(problem);
|
|
105
|
+
const listed = await run("git", ["ls-files"], { cwd });
|
|
106
|
+
const files = listed.stdout.split(/\r?\n/).map((f) => f.trim()).filter(Boolean);
|
|
107
|
+
|
|
108
|
+
/** @type {string[]} */
|
|
109
|
+
const changed = [];
|
|
110
|
+
for (const rel of files) {
|
|
111
|
+
const abs = path.join(cwd, rel);
|
|
112
|
+
if (!existsSync(abs)) continue;
|
|
113
|
+
if (rel === "README.md") {
|
|
114
|
+
writeFileSync(abs, siteReadme(siteName));
|
|
115
|
+
changed.push(rel);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const body = readFileSync(abs, "utf8");
|
|
119
|
+
if (body.includes("\0")) continue; // binary
|
|
120
|
+
if (!body.includes(MARKERS.key) && !body.includes(MARKERS.name)) continue;
|
|
121
|
+
const next = body.replaceAll(MARKERS.key, siteKey).replaceAll(MARKERS.name, nameFor(rel, siteName));
|
|
122
|
+
writeFileSync(abs, next);
|
|
123
|
+
changed.push(rel);
|
|
124
|
+
}
|
|
125
|
+
return changed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The display name, escaped for the file it lands in: inside a JSON or code
|
|
130
|
+
* string it must not end the string; inside a CSS comment it must not end the
|
|
131
|
+
* comment.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} rel
|
|
134
|
+
* @param {string} name
|
|
135
|
+
*/
|
|
136
|
+
function nameFor(rel, name) {
|
|
137
|
+
if (/\.(json|[cm]?[jt]sx?)$/.test(rel)) return JSON.stringify(name).slice(1, -1);
|
|
138
|
+
if (rel.endsWith(".css")) return name.replaceAll("*/", "");
|
|
139
|
+
return name;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {string} name */
|
|
143
|
+
function siteReadme(name) {
|
|
144
|
+
return `# ${name}
|
|
145
|
+
|
|
146
|
+
${name}'s website, built from WMM's site template and connected to WMM Studio.
|
|
147
|
+
|
|
148
|
+
- Copy: \`dictionaries/en.json\` and \`es.json\` (same keys in both).
|
|
149
|
+
- Brand colours: \`app/client-theme.css\`.
|
|
150
|
+
- What the client can edit in Studio: \`lib/content-manifest.ts\`.
|
|
151
|
+
- Rules for working on this site: \`CLAUDE.md\`.
|
|
152
|
+
|
|
153
|
+
Before every push: \`npm run verify\`.
|
|
154
|
+
`;
|
|
155
|
+
}
|
package/cli/state.mjs
CHANGED
|
@@ -10,12 +10,24 @@ import path from "node:path";
|
|
|
10
10
|
* @typedef {{
|
|
11
11
|
* siteKey: string, siteName: string, studioUrl: string, manifestUrl: string,
|
|
12
12
|
* revalidateUrl: string, modules: string[], readyToken: string, done: string[],
|
|
13
|
-
* theme?: string, scope?: string, baseBranch?: string, siteHasKey?: boolean
|
|
13
|
+
* theme?: string, scope?: string, baseBranch?: string, siteHasKey?: boolean,
|
|
14
|
+
* mode?: "new" | "existing", repoCreated?: boolean
|
|
14
15
|
* }} OnboardingState
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
const DIR = ".wmm-onboarding";
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* True when `.gitignore` is the one-line file saveState wrote into a folder that
|
|
22
|
+
* had none: the command's own file, safe to treat as absent.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} cwd
|
|
25
|
+
*/
|
|
26
|
+
export function isOwnGitignore(cwd) {
|
|
27
|
+
const file = path.join(cwd, ".gitignore");
|
|
28
|
+
return existsSync(file) && readFileSync(file, "utf8").trim() === `${DIR}/`;
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
/** @param {string} cwd @returns {OnboardingState | null} */
|
|
20
32
|
export function loadState(cwd) {
|
|
21
33
|
const file = path.join(cwd, DIR, "state.json");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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. Use when the init command opens Claude Code on /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
4
|
---
|
|
5
5
|
|
|
6
6
|
# Finish connecting this app to WMM Studio
|
|
@@ -21,6 +21,35 @@ Read `.wmm-onboarding/state.json` first: it lists the modules this site uses
|
|
|
21
21
|
(`content`, `analytics`, `forms`, `attribution`, `ab`, …). Skip anything for a module
|
|
22
22
|
that is not listed.
|
|
23
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
|
+
|
|
24
53
|
## 1. Root layout (`app/layout.tsx`, or the layout that renders `<html>`)
|
|
25
54
|
|
|
26
55
|
- **Brand (content):** `import { brandRootAttributes } from "@web-my-money/studio-consumer/brand";`
|
package/src/analytics/index.ts
CHANGED
package/src/attribution/index.ts
CHANGED
package/src/brand/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Pure on purpose: no `server-only`, no Next imports, safe in any component.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
export const PACKAGE_VERSION = "2.
|
|
19
|
+
export const PACKAGE_VERSION = "2.4.0";
|
|
20
20
|
|
|
21
21
|
/** Theme ids owned by WMM in the tokens package (themes.json `owner: "wmm"`). */
|
|
22
22
|
export const WMM_THEMES = ["wmm", "site", "light", "dark"] as const;
|
package/src/content/index.ts
CHANGED
package/src/image/index.ts
CHANGED
package/src/next/index.d.mts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// Hand-maintained alongside index.mjs.
|
|
2
|
-
|
|
3
|
-
type HeaderRule = { source: string; headers: { key: string; value: string }[] };
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Wrap a site's next.config so Studio can edit it: transpiles this package,
|
|
7
|
-
* sets `frame-ancestors 'self' <studio>` (spliced into an existing CSP), and
|
|
8
|
-
* refuses X-Frame-Options. Wrapping twice is the same as once.
|
|
9
|
-
*/
|
|
10
|
-
export declare function withStudio<T extends object>(
|
|
11
|
-
config: T,
|
|
12
|
-
opts?: { studioOrigins?: string[] },
|
|
13
|
-
): T & { transpilePackages: string[]; headers: () => Promise<HeaderRule[]> };
|
|
1
|
+
// Hand-maintained alongside index.mjs.
|
|
2
|
+
|
|
3
|
+
type HeaderRule = { source: string; headers: { key: string; value: string }[] };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wrap a site's next.config so Studio can edit it: transpiles this package,
|
|
7
|
+
* sets `frame-ancestors 'self' <studio>` (spliced into an existing CSP), and
|
|
8
|
+
* refuses X-Frame-Options. Wrapping twice is the same as once.
|
|
9
|
+
*/
|
|
10
|
+
export declare function withStudio<T extends object>(
|
|
11
|
+
config: T,
|
|
12
|
+
opts?: { studioOrigins?: string[] },
|
|
13
|
+
): T & { transpilePackages: string[]; headers: () => Promise<HeaderRule[]> };
|