@web-my-money/studio-consumer 2.1.0 → 2.3.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 +37 -1
- package/bin/studio-consumer.mjs +29 -0
- package/cli/claude-step.mjs +48 -0
- package/cli/init.mjs +439 -0
- package/cli/preflight.mjs +78 -0
- package/cli/provisioner.mjs +102 -0
- package/cli/run.mjs +89 -0
- package/cli/state.mjs +37 -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 +96 -0
- package/src/analytics/collect-handler.ts +70 -4
- 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 +19 -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/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.3.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>,
|
|
@@ -31,7 +28,20 @@ export function createManifestHandler(
|
|
|
31
28
|
*/
|
|
32
29
|
brand: SiteBrand,
|
|
33
30
|
): () => Promise<Response> {
|
|
31
|
+
// TypeScript requires the brand; a JS caller or a cast can still skip it. Fail
|
|
32
|
+
// where the mistake is (the site's build), not at Studio's sync. Frozen is the
|
|
33
|
+
// mark of defineSiteBrand, which validated it.
|
|
34
|
+
if (!brand || !Object.isFrozen(brand)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"createManifestHandler: pass the site's brand from defineSiteBrand(...) as the second argument.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
34
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();
|
|
35
45
|
const manifest = await getManifest();
|
|
36
46
|
return NextResponse.json(
|
|
37
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.3.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
|
+
}
|