@web-my-money/studio-consumer 2.2.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 -88
- package/bin/studio-consumer.mjs +29 -0
- package/cli/claude-step.mjs +48 -0
- package/cli/init.mjs +489 -0
- package/cli/preflight.mjs +103 -0
- package/cli/provisioner.mjs +103 -0
- package/cli/run.mjs +89 -0
- package/cli/scaffold.mjs +155 -0
- package/cli/state.mjs +49 -0
- package/cli/studio-api.mjs +127 -0
- package/cli/templates.mjs +134 -0
- package/cli/ui.mjs +126 -0
- package/cli/writers.mjs +148 -0
- package/package.json +33 -29
- package/skills/onboard-site/SKILL.md +125 -0
- package/src/analytics/index.ts +37 -37
- package/src/attribution/index.ts +14 -14
- package/src/brand/index.ts +66 -66
- package/src/content/index.ts +27 -27
- package/src/content/manifest-handler.ts +11 -9
- package/src/image/index.ts +14 -14
- package/src/next/index.d.mts +13 -0
- package/src/next/index.mjs +103 -0
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.
|
|
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.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,27 +1,27 @@
|
|
|
1
|
-
/** Proves at runtime which build a consumer is actually running. */
|
|
2
|
-
export const PACKAGE_VERSION = "2.
|
|
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.0";
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
createContentClient,
|
|
6
|
+
type ContentClient,
|
|
7
|
+
type ContentManifest,
|
|
8
|
+
type ManifestSlot,
|
|
9
|
+
type StudioForm,
|
|
10
|
+
} from "./payload";
|
|
11
|
+
export { createManifestHandler } from "./manifest-handler";
|
|
12
|
+
export { createRevalidateHandler } from "./revalidate-handler";
|
|
13
|
+
export { studioFrameAncestors } from "./headers";
|
|
14
|
+
export {
|
|
15
|
+
overrideValueForDict,
|
|
16
|
+
resolveLocalized,
|
|
17
|
+
setPath,
|
|
18
|
+
withSlotOverride,
|
|
19
|
+
} from "./dict-overrides";
|
|
20
|
+
export {
|
|
21
|
+
WmmEditOverlay,
|
|
22
|
+
StudioSlotPreviewBridge,
|
|
23
|
+
StudioFormPreviewBridge,
|
|
24
|
+
type PreviewableSlot,
|
|
25
|
+
type StudioPreviewFormProps,
|
|
26
|
+
type StudioPreviewFormComponent,
|
|
27
|
+
} from "./preview";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NextResponse } from "next/server";
|
|
1
|
+
import { connection, NextResponse } from "next/server";
|
|
2
2
|
import type { SiteBrand } from "../brand/index";
|
|
3
3
|
import type { ContentManifest } from "./payload";
|
|
4
4
|
|
|
@@ -12,14 +12,11 @@ import type { ContentManifest } from "./payload";
|
|
|
12
12
|
* Takes the app's own `getManifest` so the manifest itself — the content model —
|
|
13
13
|
* stays in the consuming app, not in this package.
|
|
14
14
|
*
|
|
15
|
-
* The
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* Leaving it off silently changes the route from always-fresh to
|
|
21
|
-
* statically-cached at build time, which means Studio's manifest sync would
|
|
22
|
-
* reconcile against stale slot defaults instead of the live dictionary.
|
|
15
|
+
* The handler waits for a real request (`connection()`), so the route is always
|
|
16
|
+
* fresh: with or without `cacheComponents`. No `export const dynamic` is needed
|
|
17
|
+
* in the route file any more, and under `cacheComponents` Next refuses one. An
|
|
18
|
+
* existing `export const dynamic = "force-dynamic"` in an app WITHOUT
|
|
19
|
+
* cacheComponents is harmless and can stay.
|
|
23
20
|
*/
|
|
24
21
|
export function createManifestHandler(
|
|
25
22
|
getManifest: () => Promise<ContentManifest>,
|
|
@@ -40,6 +37,11 @@ export function createManifestHandler(
|
|
|
40
37
|
);
|
|
41
38
|
}
|
|
42
39
|
return async function GET() {
|
|
40
|
+
// Wait for a real request. In an app with `cacheComponents` on, a GET route
|
|
41
|
+
// that reads no request data is prerendered at build time, so Studio would
|
|
42
|
+
// sync against a stale manifest; `export const dynamic` is not allowed there.
|
|
43
|
+
// Without cacheComponents this is a no-op (GET routes are dynamic already).
|
|
44
|
+
await connection();
|
|
43
45
|
const manifest = await getManifest();
|
|
44
46
|
return NextResponse.json(
|
|
45
47
|
{ ...manifest, brand: { theme: brand.theme, style: brand.style, wmmSite: brand.wmmSite } },
|
package/src/image/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/** Pure; safe in client components. */
|
|
2
|
-
export const PACKAGE_VERSION = "2.
|
|
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.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
|
+
}
|
|
@@ -0,0 +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[]> };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { studioFrameAncestors } from "../content/headers.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `withStudio(nextConfig)` — the one next.config edit the onboarding command
|
|
6
|
+
* makes (spec 2026-10-09 §3.3). Plain ESM for the same reason as headers.mjs:
|
|
7
|
+
* Next loads next.config through native `import()`, which cannot reach the
|
|
8
|
+
* package's `.ts` entries.
|
|
9
|
+
*
|
|
10
|
+
* - transpiles this package (it ships TypeScript source);
|
|
11
|
+
* - lets Studio frame the site's pages for click-to-edit, and nothing else:
|
|
12
|
+
* `frame-ancestors 'self' <studio>` on every page. An existing
|
|
13
|
+
* Content-Security-Policy is spliced into, never duplicated: browsers apply
|
|
14
|
+
* the strictest of several CSP headers, so a second one would silently win
|
|
15
|
+
* or lose;
|
|
16
|
+
* - refuses X-Frame-Options, which has no allowlist and would block Studio.
|
|
17
|
+
*
|
|
18
|
+
* Wrapping twice is the same as wrapping once.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const PACKAGE = "@web-my-money/studio-consumer";
|
|
22
|
+
const DEFAULT_ORIGINS = ["https://studio.webmymoney.com"];
|
|
23
|
+
const WRAPPED = Symbol.for("wmm.withStudio");
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ key: string, value: string }} HeaderEntry
|
|
27
|
+
* @typedef {{ source: string, headers: HeaderEntry[] } & Record<string, unknown>} HeaderRule
|
|
28
|
+
* @typedef {{ transpilePackages?: string[], headers?: () => Promise<HeaderRule[]> } & Record<string, unknown>} NextLikeConfig
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} csp
|
|
33
|
+
* @param {string} directive
|
|
34
|
+
*/
|
|
35
|
+
function spliceFrameAncestors(csp, directive) {
|
|
36
|
+
const parts = csp
|
|
37
|
+
.split(";")
|
|
38
|
+
.map((p) => p.trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
const at = parts.findIndex((p) => /^frame-ancestors(\s|$)/i.test(p));
|
|
41
|
+
if (at === -1) parts.push(directive);
|
|
42
|
+
else parts[at] = directive;
|
|
43
|
+
return parts.join("; ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @template {NextLikeConfig} T
|
|
48
|
+
* @param {T} config
|
|
49
|
+
* @param {{ studioOrigins?: string[] }} [opts]
|
|
50
|
+
* @returns {T & { transpilePackages: string[], headers: () => Promise<HeaderRule[]> }}
|
|
51
|
+
*/
|
|
52
|
+
export function withStudio(config, opts = {}) {
|
|
53
|
+
if (/** @type {Record<symbol, unknown>} */ (/** @type {unknown} */ (config))[WRAPPED]) {
|
|
54
|
+
return /** @type {T & { transpilePackages: string[], headers: () => Promise<HeaderRule[]> }} */ (config);
|
|
55
|
+
}
|
|
56
|
+
// Validates the origins now (wildcards, paths), so a bad list fails the build.
|
|
57
|
+
const directive = studioFrameAncestors(opts.studioOrigins ?? DEFAULT_ORIGINS).value;
|
|
58
|
+
const original = config.headers;
|
|
59
|
+
|
|
60
|
+
const transpilePackages = [...(config.transpilePackages ?? [])];
|
|
61
|
+
if (!transpilePackages.includes(PACKAGE)) transpilePackages.push(PACKAGE);
|
|
62
|
+
|
|
63
|
+
/** @returns {Promise<HeaderRule[]>} */
|
|
64
|
+
async function headers() {
|
|
65
|
+
const rules = original ? [...(await original())] : [];
|
|
66
|
+
for (const rule of rules) {
|
|
67
|
+
for (const h of rule.headers ?? []) {
|
|
68
|
+
if (h.key.toLowerCase() === "x-frame-options") {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`withStudio: remove X-Frame-Options from the "${rule.source}" rule. It cannot allow Studio to frame the page; withStudio sets frame-ancestors instead.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const out = rules.map((rule) => ({
|
|
76
|
+
...rule,
|
|
77
|
+
headers: (rule.headers ?? []).map((h) => {
|
|
78
|
+
if (h.key.toLowerCase() !== "content-security-policy") return h;
|
|
79
|
+
return { key: h.key, value: spliceFrameAncestors(h.value, directive) };
|
|
80
|
+
}),
|
|
81
|
+
}));
|
|
82
|
+
// Every page must carry the directive. A CSP on a narrow route, or on a
|
|
83
|
+
// /:path* rule that only applies sometimes (has/missing), does not cover
|
|
84
|
+
// the rest, so an unconditional /:path* rule gets one too.
|
|
85
|
+
const unconditional = (/** @type {HeaderRule} */ r) => r.source === "/:path*" && !("has" in r) && !("missing" in r);
|
|
86
|
+
const covered = out.some(
|
|
87
|
+
(r) => unconditional(r) && r.headers.some((h) => h.key.toLowerCase() === "content-security-policy"),
|
|
88
|
+
);
|
|
89
|
+
if (!covered) {
|
|
90
|
+
const all = out.find(unconditional);
|
|
91
|
+
if (all) all.headers = [...all.headers, { key: "Content-Security-Policy", value: directive }];
|
|
92
|
+
else out.push({ source: "/:path*", headers: [{ key: "Content-Security-Policy", value: directive }] });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const wrapped = { ...config, transpilePackages, headers };
|
|
98
|
+
// Non-enumerable: Next validates config keys and would warn about a marker.
|
|
99
|
+
Object.defineProperty(wrapped, WRAPPED, { value: true, enumerable: false });
|
|
100
|
+
return /** @type {T & { transpilePackages: string[], headers: () => Promise<HeaderRule[]> }} */ (
|
|
101
|
+
/** @type {unknown} */ (wrapped)
|
|
102
|
+
);
|
|
103
|
+
}
|