@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.
@@ -1,27 +1,27 @@
1
- /** Proves at runtime which build a consumer is actually running. */
2
- export const PACKAGE_VERSION = "2.1.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
+ /** 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 consumer's route file must ALSO export `export const dynamic =
16
- * "force-dynamic";` alongside `export const GET = createManifestHandler(...)`.
17
- * The source route carried that export at module scope, and Next requires it
18
- * literally in the route file — re-exporting it through this factory would not
19
- * register with Next's route config, so this package cannot carry it for you.
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 } },
@@ -1,14 +1,14 @@
1
- /** Pure; safe in client components. */
2
- export const PACKAGE_VERSION = "2.1.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
- }
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
+ }