From c5716ddaf93f0f092b7dd71135fad91086b4e4e4 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 10 Nov 2026 10:33:15 -0300 Subject: [PATCH] feat(nextjs,tanstack): v8-only StudioBridge and DecoDocument, Cypress-safe "." shortcut @decocms/nextjs's StudioBridge and @decocms/tanstack's DecoDocument / DecoPreview no longer render v7's framed handler (scrollToComponent, editor::rerender), whose inline script compares window.self with window.top. Cypress's modifyObstructiveCode rewrites that comparison in the proxied HTML, including inside the RSC payload that carries the script, so a Next.js page's Flight stream comes out one byte off and every page throws "Connection closed.". Both now render the v8 Studio channel (exact studioOrigins()) plus v7's "." edit shortcut as a script of its own (studioShortcut.ts, the same file in both bindings) that matches none of Cypress's rewrite regexes and keeps the shadow-root field check. Props are unchanged. @decocms/blocks/preview is untouched. Co-Authored-By: Claude Opus 5.5 --- .../references/studio-bridge.md | 8 +- .../references/document-and-security.md | 17 +- CLAUDE.md | 4 +- examples/nextjs-binding-smoke/smoke.mjs | 8 +- packages/nextjs/README.md | 2 +- packages/nextjs/src/StudioBridge.ts | 68 +++-- packages/nextjs/src/components.test.tsx | 15 +- packages/nextjs/src/index.ts | 11 +- packages/nextjs/src/studio.ts | 5 +- packages/nextjs/src/studioShortcut.ts | 99 ++++++++ packages/tanstack/README.md | 4 +- packages/tanstack/src/react/DecoDocument.tsx | 32 +-- packages/tanstack/src/react/react.test.tsx | 44 +++- packages/tanstack/src/studio.ts | 3 +- packages/tanstack/src/studioShortcut.ts | 99 ++++++++ tests/studio-shortcut.test.ts | 233 ++++++++++++++++++ 16 files changed, 565 insertions(+), 87 deletions(-) create mode 100644 packages/nextjs/src/studioShortcut.ts create mode 100644 packages/tanstack/src/studioShortcut.ts create mode 100644 tests/studio-shortcut.test.ts diff --git a/.agents/skills/deco-nextjs-site/references/studio-bridge.md b/.agents/skills/deco-nextjs-site/references/studio-bridge.md index 05b971e2..45929dc3 100644 --- a/.agents/skills/deco-nextjs-site/references/studio-bridge.md +++ b/.agents/skills/deco-nextjs-site/references/studio-bridge.md @@ -37,9 +37,11 @@ layout from being cached. ## What it does -- **The bridge script** answers Studio's scroll-to-section and rerender - messages, only from a parent window at one of Studio's origins. Outside a - frame it answers no message and only listens for the "." shortcut. +- **The shortcut script** listens for the "." shortcut, only outside a frame. + It answers no message: v8 only, v7's framed handler (scroll-to-section, + rerender) is gone, so no script on the page compares the window with `top` + or `parent`, and a test runner that rewrites "frame-busting" code (Cypress) + serves the page as is. - **The Studio channel** runs on every page, production included (about 0.7 kB gzipped). Framed by a trusted origin, it posts `{ type: "deco:ready" }` and imports a module only when the answer `{ type: "deco:load", url }` diff --git a/.agents/skills/deco-tanstack-site/references/document-and-security.md b/.agents/skills/deco-tanstack-site/references/document-and-security.md index e3b4c0bf..fbc9c7b4 100644 --- a/.agents/skills/deco-tanstack-site/references/document-and-security.md +++ b/.agents/skills/deco-tanstack-site/references/document-and-security.md @@ -50,7 +50,7 @@ What else it mounts: after hydration. Work that must follow hydration waits for it. - `NavigationProgress` and `StableOutlet` ([`router-and-navigation.md`](./router-and-navigation.md)). - An inline script that publishes the request's draft and CDN segment token to `window`. -- `StudioBridge`, `StudioChannel` and `DraftBadge` from `@decocms/blocks/preview` (below). +- The "." edit shortcut, and `StudioChannel` and `DraftBadge` from `@decocms/blocks/preview` (below). ## The head @@ -76,18 +76,18 @@ import { DecoPreview } from "@decocms/tanstack/react"; ``` `DecoPreview` takes `site`, `editShortcut`, `nonce` and `studioOrigins`. It renders the draft -script, `StudioBridge`, `StudioChannel` and `DraftBadge`. `StudioBridge` alone leaves draft pages -without the badge. You then own ``, ``, `lang`, the events bus and the +script, the "." shortcut, `StudioChannel` and `DraftBadge`. You then own ``, ``, `lang`, the events bus and the progress bar. ## Studio inside the page -- **`StudioBridge`**: in Studio's frame it talks to Studio. Outside it, "." opens the current page - in Studio's editor: the page whose route returns `pathTemplate` (and optionally `pageId`) in its - loader data, else Studio picks it from the URL. +- **The "." shortcut**: outside Studio's frame, "." opens the current page in Studio's editor: the + page whose route returns `pathTemplate` (and optionally `pageId`) in its loader data, else Studio + picks it from the URL. In a frame it does nothing (v8 only: no v7 `scrollToComponent` / + `editor::rerender` handler). - **`StudioChannel`**: on every page, production included, about 0.7 kB gzipped. When a trusted Studio origin frames the page, it loads Studio's editor modules from that origin. Otherwise it - does nothing. The bridge runs no script Studio sends. + does nothing. It is the only way Studio's editor reaches the page. - **`DraftBadge`**: shown on a draft page outside Studio's frame. A draft link that can't be read anymore shows a notice and the release. @@ -98,8 +98,7 @@ from `@decocms/tanstack`). Each entry must be an exact `http(s)` origin; anythin The worker puts the same list into the CSP. If you set `studioOrigins` on `DecoDocument`, set the same list on `createDecoWorker`; `DECO_STUDIO_ORIGIN` sets both. -When Studio asks the page to scroll to a section, `StudioBridge` looks for the element whose -`data-manifest-key` equals the id Studio sends, else the element with that `id`. How to render +Studio's editor modules find the page's sections by their `data-manifest-key`. How to render sections: `deco-site-authoring`. ## Security headers diff --git a/CLAUDE.md b/CLAUDE.md index 8a12a222..7cc66dfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ Opinionated by design: the behaviour every Deco TanStack site should have is fix | `@decocms/tanstack/server` | `src/server/index.ts` — `contentClient(cms, request?)` (`cms.client({ version, visitor, experiments, campaigns })` from the worker's `x-deco-version`, `x-deco-visitor` and `x-deco-assignments`: the page decides nothing again), `requestVersion`, `requestVisitor`, `requestAssignments`, `markPrivate`, `isServerFnCall`. Server only | | `@decocms/tanstack/start` | `src/start/index.ts` — `decoServerFnFetch` for `createStart({ serverFns: { fetch } })`. Browser-safe | | `@decocms/tanstack/router` | `src/router/index.ts` — `createDecoRouter`, `decoParseSearch`/`decoStringifySearch` (keep `?q=`, v7 #646), `PAGE_STATUS_HEADER`. Isomorphic | -| `@decocms/tanstack/react` | `src/react/index.ts` — `DecoDocument` (mounts `StudioBridge`, `StudioChannel` (its `studioOrigins` prop, else `studioOrigins()` of `src/studio.ts`: `DECO_STUDIO_ORIGIN` comma-separated, else `DEFAULT_STUDIO_ORIGINS`) and `DraftBadge` from `@decocms/blocks/preview`, publishes the `__cseg` token and the draft), `NavigationProgress`, `StableOutlet`; `Image`, `Picture`, `Source` (v7's props and markup) with `ImageUrlProvider` for the site's `ImageUrl` from `@decocms/blocks/image`; no platform knowledge (without a provider, `DECO_IMAGE_URL`: every source through the Deco CDN), the site lists its platform's helper (`@decocms/apps-vtex/image`, …) itself. `tests/tanstack-image-markup.golden.test.ts` holds v7's markup byte for byte (250 cases: no width and Nuvemshop images included, the latter with a store's own provider) with those helpers, outside the package since it depends on no `@decocms/apps-*`. Browser-safe | +| `@decocms/tanstack/react` | `src/react/index.ts` — `DecoDocument` (mounts the "." shortcut (`src/studioShortcut.ts`, the same file as `@decocms/nextjs`'s; v8 only, no v7 framed handler, Cypress-safe: `tests/studio-shortcut.test.ts`), `StudioChannel` (its `studioOrigins` prop, else `studioOrigins()` of `src/studio.ts`: `DECO_STUDIO_ORIGIN` comma-separated, else `DEFAULT_STUDIO_ORIGINS`) and `DraftBadge` from `@decocms/blocks/preview`, publishes the `__cseg` token and the draft), `NavigationProgress`, `StableOutlet`; `Image`, `Picture`, `Source` (v7's props and markup) with `ImageUrlProvider` for the site's `ImageUrl` from `@decocms/blocks/image`; no platform knowledge (without a provider, `DECO_IMAGE_URL`: every source through the Deco CDN), the site lists its platform's helper (`@decocms/apps-vtex/image`, …) itself. `tests/tanstack-image-markup.golden.test.ts` holds v7's markup byte for byte (250 cases: no width and Nuvemshop images included, the latter with a store's own provider) with those helpers, outside the package since it depends on no `@decocms/apps-*`. Browser-safe | | `@decocms/tanstack/vite` | `src/vite/index.ts` — `decoTanstack({ buildHash })` (= `decoContent()` from `@decocms/blocks/vite`, `__BUILD_HASH__`, a browser stub for `node:async_hooks`, `resolve.dedupe`), `gitBuildHash` | | `@decocms/tanstack/scope` | `src/scope/server.ts` / `browser.ts` — the request scope the worker hands the render (`AsyncLocalStorage`). Conditional export: `workerd` and `node` before `browser`, because a Workers build turns `browser` on too; `src/__tests__/exports.test.ts` resolves it under those conditions | @@ -117,7 +117,7 @@ Small helpers for any Next.js App Router app, with no hosting assumptions (no CD |---|---| | `@decocms/nextjs/proxy` | `src/proxy/index.ts` — `decideDraft(cms, request)` (the CMS's preview hosts, `cms.readVersion`, `src/drafts.ts`), `draftRequestHeaders` (`x-deco-version`; forged `x-deco-version`/`x-deco-visitor`/`x-deco-assignments` removed), `rewriteToDraftRoute` (`/_draft`, with the visitor and the assignments when given), `applyDraft` (cookie, `draftResponseHeaders`), `isDraftRoutePath`, `decideVisitor` (`readVisitor`; the cookie on a first visit only, never from a preview; reads no content), `visitorRequestHeaders` (`x-deco-visitor`), `decideAssignments` (every test and campaign, decided once: `cms.client({ visitor, ...version })`), `assignmentsRequestHeaders` (`x-deco-assignments`), `applyVisitor`, the assignments route (`rewriteToAssignmentsRoute`, `assignmentsSegment`: every name with its state), `draftProxy` (all of it). `DraftDecision.version` is `cms.readVersion`'s shape (blocks' `parseVersion` after the binding's host check). No React, no `next/headers`, no block map (`src/__tests__/exports.test.ts`) | | `@decocms/nextjs/server` | `src/server/index.ts` — `bindDraft(cms, { searchParams })` (param, then `x-deco-version`, then cookie; the same host check), `activeDraftPointer`, `contentClient(cms)` (`cms.client({ version, experiments, campaigns })` with what the proxy decided), `assignedClient(cms, segment)` (async: the assignments route's fully decided client, a test at 0% kept off); `bindDraft` also binds the proxy's `x-deco-visitor` and `x-deco-assignments` (for a preview always; on the current release only when it saves an `experiments` or `campaigns` block, so a static page stays static, and then a test at 0% stays off whatever the header says). The draft lives in React's `cache()` (`server/slot.ts`), so no conditional export is needed | -| `@decocms/nextjs` | `src/index.ts` — `DraftPreviewIndicator` (renders `DraftBadge` for the bound draft), `DraftBadge`, `StudioBridge` (`src/StudioBridge.ts`: blocks' bridge with `trustedOrigins` set to the package's `STUDIO_ORIGINS`, followed by blocks' `StudioChannel` for `studioOrigins(props.studioOrigins)`), `withStudioCsp` (`src/csp.ts`: the site's own CSP with Studio's origins in `script-src` and `frame-ancestors`; the binding sends no CSP itself), and the package's own draft names and Studio origins: `DRAFT_PARAM`/`DRAFT_COOKIE`/`VERSION_HEADER`, `draftResponseHeaders` (`src/draft.ts`), `VISITOR_HEADER` (`src/visitor.ts`), `ASSIGNMENTS_HEADER` (`src/assignments.ts`), `STUDIO_ORIGINS`/`STUDIO_FRAME_ANCESTORS`/`isStudioOrigin`/`STUDIO_ORIGIN`/`DEFAULT_STUDIO_ORIGINS`/`studioOrigins` (`src/studio.ts`). The same values as `@decocms/tanstack`'s, never imported from it (`exports.test.ts`) | +| `@decocms/nextjs` | `src/index.ts` — `DraftPreviewIndicator` (renders `DraftBadge` for the bound draft), `DraftBadge`, `StudioBridge` (`src/StudioBridge.ts`, v8 only: the "." shortcut, `src/studioShortcut.ts` (no v7 framed handler, so nothing a Cypress frame-busting rewrite matches: `tests/studio-shortcut.test.ts`), followed by blocks' `StudioChannel` for `studioOrigins(props.studioOrigins)`), `withStudioCsp` (`src/csp.ts`: the site's own CSP with Studio's origins in `script-src` and `frame-ancestors`; the binding sends no CSP itself), and the package's own draft names and Studio origins: `DRAFT_PARAM`/`DRAFT_COOKIE`/`VERSION_HEADER`, `draftResponseHeaders` (`src/draft.ts`), `VISITOR_HEADER` (`src/visitor.ts`), `ASSIGNMENTS_HEADER` (`src/assignments.ts`), `STUDIO_ORIGINS`/`STUDIO_FRAME_ANCESTORS`/`isStudioOrigin`/`STUDIO_ORIGIN`/`DEFAULT_STUDIO_ORIGINS`/`studioOrigins` (`src/studio.ts`). The same values as `@decocms/tanstack`'s, never imported from it (`exports.test.ts`) | | `@decocms/nextjs/config` | `src/config/index.cjs` (+ `index.d.cts`), plain CommonJS shipped from `src` — `withDeco(nextConfig, { draftHeaders, studioOrigins })`: v7's draft header rules first, `STUDIO_ORIGINS` in `allowedDevOrigins`; no rewrites, no image rules. It repeats the package's names (`src/draft.ts`, `src/studio.ts`), held equal by `config.test.ts` | | `@decocms/nextjs/instrumentation` | `src/instrumentation/index.ts` — `register()` (the site's `instrumentation.ts`: a minimal global tracer provider whose only job is Next's root `BaseServer.handleRequest` span, recorded with `recordServerRequest` from `@decocms/blocks/metrics`; exports no span, non-recording for every other span; skips the proxy's own bubbled `handleRequest`; no-op on Edge, without `@opentelemetry/api` (the app's, else Next's compiled copy) or when another provider is registered) and `serverRequestSpanProcessor()` for a site with its own OpenTelemetry SDK. `@opentelemetry/api` is an optional peer | | `@decocms/nextjs/image-loader` | `src/image-loader/index.ts` — `createDecoImageLoader(imageUrl, { fallback })`, `nextImageOptimizer`. Platform-neutral: the site composes its platform's helpers (`@decocms/apps-vtex/image`, …) into the `ImageUrl`; the package depends on no `@decocms/apps-*`. `tests/nextjs-image-loader.golden.test.ts` runs the next/image golden URLs of blocks and apps-vtex through that composition (no changed case but the PO-approved fix-2 ones, marked `before`/`reason` in the fixture) | diff --git a/examples/nextjs-binding-smoke/smoke.mjs b/examples/nextjs-binding-smoke/smoke.mjs index 3a2f6f51..2af06a69 100644 --- a/examples/nextjs-binding-smoke/smoke.mjs +++ b/examples/nextjs-binding-smoke/smoke.mjs @@ -100,10 +100,10 @@ try { check("a published page is not noindex", !page.res.headers.has("x-robots-tag")); check("the layout mounts the Studio bridge", page.body.includes('data-site="binding-smoke"')); check( - "the bridge answers Studio's origins (the package's STUDIO_ORIGINS)", - page.body.includes( - 'data-origins="["https://studio.decocms.com","https://studio-stg.decocms.com","https://decocms.com","https://*.decocms.com"]"', - ), + "the bridge is v8 only: the Studio channel, and no v7 framed handler", + page.body.includes("data-deco-studio-channel") && + !page.body.includes("scrollToComponent") && + !page.body.includes("window.self!==window.top"), page.body.match(/data-origins="[^"]*"/)?.[0], ); check( diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 306ee4a7..dacc1e7f 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -130,7 +130,7 @@ import { StudioBridge } from "@decocms/nextjs"; ``` -Studio's origins are the package's own (`STUDIO_ORIGINS`), so the bridge takes only the site's name. v7's "." edit shortcut is on by default (`editShortcut={false}` turns it off), and `nonce` is optional. +Studio's origins are the package's own, so the bridge takes only the site's name. v7's "." edit shortcut is on by default (`editShortcut={false}` turns it off), and `nonce` is optional. The bridge is v8 only: v7's framed handler (`scrollToComponent`, `editor::rerender`) is gone, so no script on the page compares the window with `top` or `parent`, and a test runner that rewrites "frame-busting" code in the HTML it proxies (Cypress's `modifyObstructiveCode`) serves the page byte for byte. The bridge also mounts the Studio channel on every page, production included: a small inline script (about 0.7 kB gzipped) through which Studio loads its own editor code (the section wireframe, the page swap after a save) into the page when it frames it. It is the only way Studio runs code in the page: the bridge evaluates no script Studio sends. The page trusts a list of exact Studio origins: `studioOrigins` when given, else the `DECO_STUDIO_ORIGIN` environment variable, comma-separated (a local Studio, such as `http://localhost:3000`), else Studio's production and staging origins (`DEFAULT_STUDIO_ORIGINS`). Framed, the page posts `{ type: "deco:ready" }` with each of them as the target origin (only a parent at one of them receives it) and imports a module only when Studio's answer, `{ type: "deco:load", url }`, comes from exactly one of them and names a URL on the origin of that message; not framed, or framed by any other origin, it does nothing. A site with a Content Security Policy passes it through `withStudioCsp(directives)`, which adds the same list to `script-src` (and a `frame-ancestors` with Studio's, when the policy has none): `headers: [{ source: "/:path*", headers: [{ key: "Content-Security-Policy", value: withStudioCsp("script-src 'self' 'unsafe-inline'; object-src 'none'").join("; ") }] }]`, or the same in the site's proxy. The inline scripts take the site's `nonce`. The message and module types are in `@decocms/blocks/protocol`. diff --git a/packages/nextjs/src/StudioBridge.ts b/packages/nextjs/src/StudioBridge.ts index e66d188f..b1f1bc52 100644 --- a/packages/nextjs/src/StudioBridge.ts +++ b/packages/nextjs/src/StudioBridge.ts @@ -1,24 +1,43 @@ /** - * `StudioBridge` of `@decocms/blocks/preview` with Studio's origins filled in - * (`STUDIO_ORIGINS`, `./studio.ts`): the package owns where Studio runs, so a - * site passes only its name. With it, the Studio channel (`StudioChannel`): - * on every page, production included, framed by one of Studio's origins - * (`studioOrigins` / `DECO_STUDIO_ORIGIN` / Studio's production and staging - * origins, each exact) it loads the editor's own modules from that origin; - * otherwise it does nothing. Two inline scripts and no hooks, so a Server - * Component renders it as is. The site's CSP allows the same origins with - * `withStudioCsp`. + * What a Next.js site renders for Deco Studio's site editor: the Studio + * channel (`StudioChannel` of `@decocms/blocks/preview`) and v7's "." edit + * shortcut (`./studioShortcut.ts`). On every page, production included, + * framed by one of Studio's origins (`studioOrigins` / `DECO_STUDIO_ORIGIN` / + * Studio's production and staging origins, each exact), the channel loads + * the editor's own modules from that origin; otherwise it does nothing. Not + * framed, "." opens the page in Studio's editor. + * + * v8 only: v7's framed handler (`scrollToComponent`, `editor::rerender`) is + * gone, so no script on the page compares the window with `top` or `parent` + * in a form a test runner's "frame-busting" rewrite matches (Cypress's + * `modifyObstructiveCode`, which would change the length of the React Server + * Components payload that carries the script). Two inline scripts and no + * hooks, so a Server Component renders it as is. The site's CSP allows the + * same origins with `withStudioCsp`. */ -import { - StudioBridge as BridgeScript, - type StudioBridgeProps as BridgeScriptProps, - StudioChannel, -} from "@decocms/blocks/preview"; +import { StudioChannel, type StudioPageInfo } from "@decocms/blocks/preview"; import { createElement, Fragment, type ReactNode } from "react"; -import { STUDIO_ORIGINS, studioOrigins } from "./studio.ts"; +import { studioOrigins } from "./studio.ts"; +import { StudioShortcut } from "./studioShortcut.ts"; -/** `site`, and optionally `editShortcut`, `page` and `nonce`; Studio's origins are the package's. */ -export interface StudioBridgeProps extends Omit { +/** `site`, and optionally `editShortcut`, `page`, `nonce` and `studioOrigins`. */ +export interface StudioBridgeProps { + /** The site's name in Deco (the "." shortcut sends it to Studio). */ + site: string; + /** + * v7's editor shortcut: on a page that isn't framed, "." (or Ctrl+Shift+E) + * opens it in Studio, and Ctrl or Cmd+"." opens it in a new tab. On by + * default, as in v7; `false` turns it off. + */ + editShortcut?: boolean; + /** + * The page being shown, when the layout knows it: the "." shortcut sends + * its path template (and id). A `StudioPage` rendered by the page wins (a + * Next.js root layout doesn't re-render per page). + */ + page?: StudioPageInfo; + /** The CSP nonce for the inline scripts, when the site sends one. */ + nonce?: string; /** * The Studio origins the Studio channel trusts, each an exact origin. * Default: `DECO_STUDIO_ORIGIN` (comma-separated), else Studio's production @@ -30,13 +49,20 @@ export interface StudioBridgeProps extends Omit`. + * frames it, and "." open the page in Studio when it doesn't. Render it once, + * in the root layout's ``. */ -export function StudioBridge({ studioOrigins: origins, ...props }: StudioBridgeProps): ReactNode { +export function StudioBridge({ + site, + editShortcut, + page, + nonce, + studioOrigins: origins, +}: StudioBridgeProps): ReactNode { return createElement( Fragment, null, - createElement(BridgeScript, { ...props, trustedOrigins: STUDIO_ORIGINS }), - createElement(StudioChannel, { studioOrigins: studioOrigins(origins), nonce: props.nonce }), + createElement(StudioShortcut, { site, editShortcut, page, nonce }), + createElement(StudioChannel, { studioOrigins: studioOrigins(origins), nonce }), ); } diff --git a/packages/nextjs/src/components.test.tsx b/packages/nextjs/src/components.test.tsx index b16138df..e4aad5a4 100644 --- a/packages/nextjs/src/components.test.tsx +++ b/packages/nextjs/src/components.test.tsx @@ -49,29 +49,26 @@ describe("DraftPreviewIndicator", () => { }); describe("StudioBridge", () => { - it("is @decocms/blocks/preview's script, answering Studio's origins (STUDIO_ORIGINS)", async () => { + it('is the "." shortcut and the Studio channel: v8 only, nothing of v7\'s framed handler', async () => { const preview = await import("@decocms/blocks/preview"); - const { STUDIO_ORIGINS } = await import("./studio.ts"); + const { StudioShortcut } = await import("./studioShortcut.ts"); const html = renderToStaticMarkup(); expect(html).toMatch(/^]*>/); expect(html).toBe( - renderToStaticMarkup() + + renderToStaticMarkup() + renderToStaticMarkup( , ), ); + expect(html).not.toContain("scrollToComponent"); + expect(html).not.toContain("editor::rerender"); const host = document.body.appendChild(document.createElement("div")); host.innerHTML = html; const script = host.querySelector("script[data-deco-studio-bridge]"); expect(script?.getAttribute("data-site")).toBe("acme"); - expect(JSON.parse(script?.getAttribute("data-origins") ?? "null")).toEqual([ - "https://studio.decocms.com", - "https://studio-stg.decocms.com", - "https://decocms.com", - "https://*.decocms.com", - ]); + expect(script?.hasAttribute("data-origins")).toBe(false); }); it("mounts the Studio channel for Studio's production and staging origins, the option, or DECO_STUDIO_ORIGIN", () => { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 1808af25..6e105026 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -5,12 +5,11 @@ * - `DraftPreviewIndicator`: the "Preview mode" badge on a page rendered from * a draft (`bindDraft` from `@decocms/nextjs/server`), outside Studio's frame. * - `StudioBridge`: one line in the root layout, ``, - * lets Studio's site editor drive the page when Studio frames it - * (`@decocms/blocks/preview`'s, answering `STUDIO_ORIGINS`). It renders an - * inline script with no React hooks, so a Server Component renders it as is. - * Outside Studio's frame, "." opens the page in Studio's editor. It also - * mounts the Studio channel (`StudioChannel`), through which Studio loads its - * own modules into the page when it frames it (`studioOrigins`). + * lets Studio's site editor drive the page when Studio frames it: it mounts + * the Studio channel (`StudioChannel` of `@decocms/blocks/preview`), through + * which Studio loads its own modules into the page (`studioOrigins`). + * Outside Studio's frame, "." opens the page in Studio's editor. Two inline + * scripts with no React hooks, so a Server Component renders it as is. * - `StudioPage`: rendered inside each CMS page, ``, * tells the bridge which page "." opens (the root layout doesn't re-render per page). * - `DRAFT_PARAM`, `DRAFT_COOKIE`, `VERSION_HEADER`, `draftResponseHeaders`, diff --git a/packages/nextjs/src/studio.ts b/packages/nextjs/src/studio.ts index 2fcd3fd5..0909dda0 100644 --- a/packages/nextjs/src/studio.ts +++ b/packages/nextjs/src/studio.ts @@ -1,8 +1,7 @@ /** * Where Deco Studio runs: the origins its site editor frames a site from and - * talks to it from (`postMessage`). One list, so the Studio bridge - * (`StudioBridge`), `withDeco()`'s dev origins and a site's own - * `frame-ancestors` never drift apart. This package's own copy: it never + * talks to it from (`postMessage`). One list, so `withDeco()`'s dev origins + * and a site's own `frame-ancestors` never drift apart. This package's own copy: it never * imports `@decocms/tanstack`. * * No module state and no framework code: safe in a browser bundle. diff --git a/packages/nextjs/src/studioShortcut.ts b/packages/nextjs/src/studioShortcut.ts new file mode 100644 index 00000000..65e01052 --- /dev/null +++ b/packages/nextjs/src/studioShortcut.ts @@ -0,0 +1,99 @@ +/** + * v7's "." edit shortcut, alone: on a page that isn't framed, "." (or + * Ctrl+Shift+E) opens the page in Studio's editor (`/choose-editor`, which + * signs a visitor who isn't signed in in first), and Ctrl or Cmd+"." opens it + * in a new tab. In a frame it does nothing: Studio's editor reaches a framed + * page through the Studio channel (`StudioChannel`) only. + * + * Safe under a test runner that rewrites "frame-busting" code in the HTML it + * proxies (Cypress's `modifyObstructiveCode`): the script compares no window + * with `top` or `parent` in a form those rewrites match, so the HTML reaches + * the browser byte for byte (a rewrite changes the length of a React Server + * Components payload that carries the script and breaks the page). + * + * A key typed in a field is left alone, also in a field inside an open shadow + * root (a third-party widget's form): its events reach the document with the + * host as their target, so the script reads the field from the event's + * composed path. + * + * The page it sends: the last `StudioPage` marker the page rendered, else the + * `page` the binding passed, read at the keypress. An inline script with no + * hooks, so a Server Component renders it; its settings travel in `data-*` + * attributes (the same as v7's bridge's: `data-deco-studio-bridge`, + * `data-site`, `data-shortcut`, `data-path-template`, `data-page-id`) and the + * script itself is static. + * + * `@decocms/nextjs` and `@decocms/tanstack` each carry this file (neither + * imports the other); tests/studio-shortcut.test.ts keeps the two the same. + */ +import { createElement, type ReactNode } from "react"; + +/** The page the "." shortcut opens in Studio. */ +interface ShortcutPage { + /** The page block's `path`, as `matchRoute` matched it (`/products/:slug/p`, `/*`). */ + pathTemplate?: string; + /** The page block's id, when the site knows it. */ + id?: string; +} + +export interface StudioShortcutProps { + /** The site's name in Deco (the shortcut sends it to Studio). */ + site: string; + /** On by default; `false` turns it off. */ + editShortcut?: boolean; + /** The page on screen, when the component that renders the shortcut knows it. */ + page?: ShortcutPage; + /** The CSP nonce for the inline script, when the site sends one. */ + nonce?: string; +} + +/** The browser side. Static: everything it needs comes from the script element's attributes. */ +export const STUDIO_SHORTCUT_SCRIPT = `(function(){ +var s=document.currentScript,w=window,p=w.parent; +if(!s||p!==w||w.__decoStudioShortcut||!s.hasAttribute("data-shortcut"))return; +w.__decoStudioShortcut=true; +var site=s.getAttribute("data-site")||""; +function page(){ + var all=document.querySelectorAll("[data-deco-page]"),el=all.length?all[all.length-1]:null; + if(!el||!el.getAttribute("data-path-template")){el=document.querySelector("script[data-deco-studio-bridge]")||s} + return{t:el.getAttribute("data-path-template")||"",id:el.getAttribute("data-page-id")||""}; +} +document.addEventListener("keydown",function(e){ + var t=e.composedPath?e.composedPath()[0]:e.target; + if(e.defaultPrevented||(t&&(t.tagName==="INPUT"||t.tagName==="TEXTAREA"||t.tagName==="SELECT"||t.isContentEditable)))return; + if(e.key!=="."&&!(e.ctrlKey&&e.shiftKey&&e.key==="E"))return; + e.preventDefault(); + e.stopPropagation(); + var g=page(),h=new URL("https://studio.decocms.com/choose-editor"); + h.searchParams.set("site",site); + h.searchParams.set("domain",location.origin); + if(g.id)h.searchParams.set("pageId",g.id); + h.searchParams.set("path",location.pathname+location.search); + h.searchParams.set("pathTemplate",g.t||"/*"); + if((e.ctrlKey||e.metaKey)&&e.key==="."){w.open(h.toString(),"_blank");return} + location.href=h.toString(); +}); +})();`; + +/** The "." shortcut's script. Render it once, in the ``. */ +export function StudioShortcut({ + site, + editShortcut = true, + page, + nonce, +}: StudioShortcutProps): ReactNode { + return createElement("script", { + "data-deco-studio-bridge": "", + "data-site": site, + "data-shortcut": editShortcut ? "" : undefined, + // Read on each keypress, so a client-side navigation that re-renders the + // script with the next page updates them. + "data-path-template": page?.pathTemplate || undefined, + "data-page-id": page?.id || undefined, + nonce, + // Browsers hide a nonce from the DOM after parsing, which React would + // report as a hydration mismatch. + suppressHydrationWarning: true, + dangerouslySetInnerHTML: { __html: STUDIO_SHORTCUT_SCRIPT }, + }); +} diff --git a/packages/tanstack/README.md b/packages/tanstack/README.md index a85c75a7..dd20cc9c 100644 --- a/packages/tanstack/README.md +++ b/packages/tanstack/README.md @@ -58,7 +58,7 @@ A complete worker entry with a page cache is [`examples/tanstack-worker-smoke/sr ### Names and Studio's origins -The root export holds what the worker, `/server` and `DecoDocument` share, for a site's own code: the draft names (`DRAFT_PARAM` `__draft`, `DRAFT_COOKIE` `__deco_draft`), the headers the worker hands the app (`VERSION_HEADER` `x-deco-version`, `VISITOR_HEADER` `x-deco-visitor`, `ASSIGNMENTS_HEADER` `x-deco-assignments`), `draftResponseHeaders(preview)` (`max-age=0, no-cache, must-revalidate`, `Vary: Cookie`, `noindex`; pass the version, or `true` for any request with a draft signal), and Studio's origins: `STUDIO_ORIGINS`, `STUDIO_FRAME_ANCESTORS` (what the worker's `frame-ancestors` starts from) and `isStudioOrigin`; the Studio channel's exact list, `DEFAULT_STUDIO_ORIGINS` and `studioOrigins(option?)`; and `withStudioCsp(directives)`, what the worker does to the site's `csp` (Studio's origins in `script-src` and `frame-ancestors`), for a policy the site sends some other way. `DecoDocument` passes `STUDIO_ORIGINS` to the Studio bridge. +The root export holds what the worker, `/server` and `DecoDocument` share, for a site's own code: the draft names (`DRAFT_PARAM` `__draft`, `DRAFT_COOKIE` `__deco_draft`), the headers the worker hands the app (`VERSION_HEADER` `x-deco-version`, `VISITOR_HEADER` `x-deco-visitor`, `ASSIGNMENTS_HEADER` `x-deco-assignments`), `draftResponseHeaders(preview)` (`max-age=0, no-cache, must-revalidate`, `Vary: Cookie`, `noindex`; pass the version, or `true` for any request with a draft signal), and Studio's origins: `STUDIO_ORIGINS`, `STUDIO_FRAME_ANCESTORS` (what the worker's `frame-ancestors` starts from) and `isStudioOrigin`; the Studio channel's exact list, `DEFAULT_STUDIO_ORIGINS` and `studioOrigins(option?)`; and `withStudioCsp(directives)`, what the worker does to the site's `csp` (Studio's origins in `script-src` and `frame-ancestors`), for a policy the site sends some other way. ```ts import { STUDIO_ORIGINS, VERSION_HEADER } from "@decocms/tanstack"; @@ -81,7 +81,7 @@ export const startInstance = createStart(() => ({ serverFns: { fetch: decoServer export const Route = createRootRoute({ component: () => }); ``` -`DecoDocument` mounts Studio's bridge, the Studio channel and the draft badge. The Studio channel is on every page, production included: a small inline script (about 0.7 kB gzipped) through which Studio loads its own editor code (the section wireframe, the page swap after a save) into the page when it frames it. It is the only way Studio runs code in the page: the bridge evaluates no script Studio sends. The page trusts a list of exact Studio origins: the `studioOrigins` prop when given, else the `DECO_STUDIO_ORIGIN` environment variable, comma-separated (read on the server; a local Studio, such as `http://localhost:3000`), else Studio's production and staging origins (`DEFAULT_STUDIO_ORIGINS`). Framed, the page posts `{ type: "deco:ready" }` with each of them as the target origin (only a parent at one of them receives it) and imports a module only when Studio's answer, `{ type: "deco:load", url }`, comes from exactly one of them and names a URL on the origin of that message; not framed, or framed by any other origin, it does nothing. The worker adds the same list to every HTML response's CSP: to `frame-ancestors`, and to `script-src` (or `script-src-elem`; a policy that restricts scripts only through `default-src` gets a `script-src` with its sources plus Studio's). A site that sets `studioOrigins` on `DecoDocument` sets the worker's `studioOrigins` option too; `DECO_STUDIO_ORIGIN` sets both. The inline scripts take the site's `nonce`. The message and module types are in `@decocms/blocks/protocol`. A page route returns `pathTemplate` (the page block's `path`, as `matchRoute` matched it) in its loader data, so "." on the store page opens that page in Studio's editor. A site that keeps its own document (a skip link before `
`, say) renders `` in its `` instead: the bridge, the badge and the draft they read. +`DecoDocument` mounts v7's "." edit shortcut, the Studio channel and the draft badge. It is v8 only: v7's framed handler (`scrollToComponent`, `editor::rerender`) is gone, so no script on the page compares the window with `top` or `parent`, and a test runner that rewrites "frame-busting" code (Cypress's `modifyObstructiveCode`) serves the page byte for byte. The Studio channel is on every page, production included: a small inline script (about 0.7 kB gzipped) through which Studio loads its own editor code (the section wireframe, the page swap after a save) into the page when it frames it. It is the only way Studio runs code in the page: the bridge evaluates no script Studio sends. The page trusts a list of exact Studio origins: the `studioOrigins` prop when given, else the `DECO_STUDIO_ORIGIN` environment variable, comma-separated (read on the server; a local Studio, such as `http://localhost:3000`), else Studio's production and staging origins (`DEFAULT_STUDIO_ORIGINS`). Framed, the page posts `{ type: "deco:ready" }` with each of them as the target origin (only a parent at one of them receives it) and imports a module only when Studio's answer, `{ type: "deco:load", url }`, comes from exactly one of them and names a URL on the origin of that message; not framed, or framed by any other origin, it does nothing. The worker adds the same list to every HTML response's CSP: to `frame-ancestors`, and to `script-src` (or `script-src-elem`; a policy that restricts scripts only through `default-src` gets a `script-src` with its sources plus Studio's). A site that sets `studioOrigins` on `DecoDocument` sets the worker's `studioOrigins` option too; `DECO_STUDIO_ORIGIN` sets both. The inline scripts take the site's `nonce`. The message and module types are in `@decocms/blocks/protocol`. A page route returns `pathTemplate` (the page block's `path`, as `matchRoute` matched it) in its loader data, so "." on the store page opens that page in Studio's editor. A site that keeps its own document (a skip link before `
`, say) renders `` in its `` instead: the shortcut, the channel, the badge and the draft they read. ## Images diff --git a/packages/tanstack/src/react/DecoDocument.tsx b/packages/tanstack/src/react/DecoDocument.tsx index a0bf24a0..2adc5c3c 100644 --- a/packages/tanstack/src/react/DecoDocument.tsx +++ b/packages/tanstack/src/react/DecoDocument.tsx @@ -8,23 +8,28 @@ * keeps its height while the next page loads; * - the CDN segment token and the draft this request renders, for * `decoServerFnFetch` and the draft badge; - * - `StudioBridge` (in Studio's frame it answers Studio's origins, - * `STUDIO_ORIGINS`; outside it, "." opens the page in Studio's editor, with - * the page's `pathTemplate` from the route's loader data) and `DraftBadge` - * (only on a draft page outside Studio's frame), from `@decocms/blocks/preview`; * - `StudioChannel` (every page, production included: framed by one of * Studio's origins, `studioOrigins` / `DECO_STUDIO_ORIGIN` / Studio's * production and staging origins, each exact, it loads the editor's own - * modules from that origin; otherwise it does nothing). The worker adds the - * same origins to the page's CSP `script-src` and `frame-ancestors`. + * modules from that origin; otherwise it does nothing) and `DraftBadge` + * (only on a draft page outside Studio's frame), from + * `@decocms/blocks/preview`. The worker adds the same origins to the + * page's CSP `script-src` and `frame-ancestors`; + * - v7's "." edit shortcut (`../studioShortcut.ts`: outside Studio's frame, + * "." opens the page in Studio's editor, with the page's `pathTemplate` + * from the route's loader data). v8 only: v7's framed handler + * (`scrollToComponent`, `editor::rerender`) is gone, so no script compares + * the window with `top` or `parent` in a form a test runner's + * "frame-busting" rewrite matches (Cypress's `modifyObstructiveCode`). * * A site that keeps its own document renders `DecoPreview` in its `` * for the bridge, the badge and the draft they read. */ -import { DraftBadge, StudioBridge, StudioChannel } from "@decocms/blocks/preview"; +import { DraftBadge, StudioChannel } from "@decocms/blocks/preview"; import { HeadContent, Outlet, ScriptOnce, Scripts, useRouterState } from "@tanstack/react-router"; import { type ReactNode, useEffect, useRef, useState } from "react"; -import { STUDIO_ORIGINS, studioOrigins } from "../studio.ts"; +import { studioOrigins } from "../studio.ts"; +import { StudioShortcut } from "../studioShortcut.ts"; import { pageValues, publishScript } from "./pageValues.ts"; import { DATA_EVENT_OBSERVER, eventsBootstrap, PROGRESS_CSS } from "./scripts.ts"; @@ -100,7 +105,7 @@ function studioPageOf( return undefined; } -/** The Studio bridge, with the page on screen, the Studio channel, and the badge for the request's draft. */ +/** The "." shortcut, with the page on screen, the Studio channel, and the badge for the request's draft. */ function StudioPieces({ site, editShortcut, @@ -117,9 +122,8 @@ function StudioPieces({ const pageId = useRouterState({ select: (s) => studioPageOf(s.matches)?.id }); return ( <> - `, say): the request's - * draft and CDN segment token published to the browser, `StudioBridge` and - * `DraftBadge`. Render it once, in the ``. + * draft and CDN segment token published to the browser, the "." shortcut, + * `StudioChannel` and `DraftBadge`. Render it once, in the ``. */ export function DecoPreview({ site, editShortcut, nonce, studioOrigins }: DecoPreviewProps) { const values = pageValues(); @@ -182,7 +186,7 @@ export function DecoPreview({ site, editShortcut, nonce, studioOrigins }: DecoPr } export interface DecoDocumentProps { - /** The site's name in Deco (the Studio bridge's shortcut opens the page under it). */ + /** The site's name in Deco (the "." shortcut opens the page under it). */ site: string; /** ``. Default `"en"`. */ lang?: string; diff --git a/packages/tanstack/src/react/react.test.tsx b/packages/tanstack/src/react/react.test.tsx index 22cb144e..e88ab8e4 100644 --- a/packages/tanstack/src/react/react.test.tsx +++ b/packages/tanstack/src/react/react.test.tsx @@ -39,12 +39,13 @@ async function render( } describe("DecoDocument", () => { - it("renders the document with Studio's bridge, the events bus and data-theme=light", async () => { + it('renders the document with the "." shortcut, the events bus and data-theme=light', async () => { const html = await render({}); expect(html).toContain(''); expect(html).toContain('class="bg-base-200 text-base-content"'); expect(html).toContain('data-site="acme"'); - expect(html).toContain("scrollToComponent"); + expect(html).not.toContain("scrollToComponent"); + expect(html).not.toContain("editor::rerender"); expect(html).not.toContain("editor::inject"); expect(html).toContain("window.DECO.events"); expect(html).toContain('id="extra"'); @@ -74,15 +75,36 @@ describe("DecoDocument", () => { } }); - it("the Studio bridge answers Studio's origins, the package's own list", async () => { + it('is v8 only: the "." shortcut answers no message, so it carries no origin list', async () => { const html = await render({}); - const attr = html.match(/data-origins="([^"]*)"/)?.[1]; - expect(attr && JSON.parse(attr.replaceAll(""", '"'))).toEqual([ - "https://studio.decocms.com", - "https://studio-stg.decocms.com", - "https://decocms.com", - "https://*.decocms.com", - ]); + const tag = html.match(/]*data-deco-studio-bridge[^>]*>/)?.[0] ?? ""; + expect(tag).toContain('data-site="acme"'); + expect(html).not.toContain("data-origins="); + }); + + it("its Studio scripts are the \".\" shortcut's and the channel's, nothing else (tests/studio-shortcut.test.ts)", async () => { + const { StudioChannel } = await import("@decocms/blocks/preview"); + const { StudioShortcut } = await import("../studioShortcut.ts"); + const env = process.env.DECO_STUDIO_ORIGIN; + delete process.env.DECO_STUDIO_ORIGIN; + try { + const html = await render({}, { nonce: "n1" }, () => ({ pathTemplate: "/:slug/p" })); + const tags = html.match(/]*data-deco-studio-[^>]*>[\s\S]*?<\/script>/g) ?? []; + expect(tags).toEqual([ + renderToString( + , + ), + renderToString( + , + ), + ]); + expect(html).not.toMatch(/window\.(self|top)\s*[!=]==?\s*window\.(self|top)/); + } finally { + if (env !== undefined) process.env.DECO_STUDIO_ORIGIN = env; + } }); it("publishes the request's CDN segment token and draft", async () => { @@ -110,7 +132,7 @@ describe("DecoDocument", () => { }); it("turns the edit shortcut off only when told to", async () => { - // The bridge's own script mentions the attribute, so look at its tag only. + // The shortcut's own script mentions the attribute, so look at its tag only. const bridgeTag = (html: string) => html.match(/]*data-deco-studio-bridge[^>]*>/)?.[0] ?? ""; expect(bridgeTag(await render({}))).toContain("data-shortcut"); diff --git a/packages/tanstack/src/studio.ts b/packages/tanstack/src/studio.ts index e8d2a2ec..a8fb657a 100644 --- a/packages/tanstack/src/studio.ts +++ b/packages/tanstack/src/studio.ts @@ -1,8 +1,7 @@ /** * Where Deco Studio runs: the origins its site editor frames a site from and * talks to it from (`postMessage`). One list, so the worker's - * `frame-ancestors` and the Studio bridge `DecoDocument` mounts never drift - * apart. + * `frame-ancestors` and a site's own never drift apart. * * No module state and no framework code: safe in a browser bundle. */ diff --git a/packages/tanstack/src/studioShortcut.ts b/packages/tanstack/src/studioShortcut.ts new file mode 100644 index 00000000..65e01052 --- /dev/null +++ b/packages/tanstack/src/studioShortcut.ts @@ -0,0 +1,99 @@ +/** + * v7's "." edit shortcut, alone: on a page that isn't framed, "." (or + * Ctrl+Shift+E) opens the page in Studio's editor (`/choose-editor`, which + * signs a visitor who isn't signed in in first), and Ctrl or Cmd+"." opens it + * in a new tab. In a frame it does nothing: Studio's editor reaches a framed + * page through the Studio channel (`StudioChannel`) only. + * + * Safe under a test runner that rewrites "frame-busting" code in the HTML it + * proxies (Cypress's `modifyObstructiveCode`): the script compares no window + * with `top` or `parent` in a form those rewrites match, so the HTML reaches + * the browser byte for byte (a rewrite changes the length of a React Server + * Components payload that carries the script and breaks the page). + * + * A key typed in a field is left alone, also in a field inside an open shadow + * root (a third-party widget's form): its events reach the document with the + * host as their target, so the script reads the field from the event's + * composed path. + * + * The page it sends: the last `StudioPage` marker the page rendered, else the + * `page` the binding passed, read at the keypress. An inline script with no + * hooks, so a Server Component renders it; its settings travel in `data-*` + * attributes (the same as v7's bridge's: `data-deco-studio-bridge`, + * `data-site`, `data-shortcut`, `data-path-template`, `data-page-id`) and the + * script itself is static. + * + * `@decocms/nextjs` and `@decocms/tanstack` each carry this file (neither + * imports the other); tests/studio-shortcut.test.ts keeps the two the same. + */ +import { createElement, type ReactNode } from "react"; + +/** The page the "." shortcut opens in Studio. */ +interface ShortcutPage { + /** The page block's `path`, as `matchRoute` matched it (`/products/:slug/p`, `/*`). */ + pathTemplate?: string; + /** The page block's id, when the site knows it. */ + id?: string; +} + +export interface StudioShortcutProps { + /** The site's name in Deco (the shortcut sends it to Studio). */ + site: string; + /** On by default; `false` turns it off. */ + editShortcut?: boolean; + /** The page on screen, when the component that renders the shortcut knows it. */ + page?: ShortcutPage; + /** The CSP nonce for the inline script, when the site sends one. */ + nonce?: string; +} + +/** The browser side. Static: everything it needs comes from the script element's attributes. */ +export const STUDIO_SHORTCUT_SCRIPT = `(function(){ +var s=document.currentScript,w=window,p=w.parent; +if(!s||p!==w||w.__decoStudioShortcut||!s.hasAttribute("data-shortcut"))return; +w.__decoStudioShortcut=true; +var site=s.getAttribute("data-site")||""; +function page(){ + var all=document.querySelectorAll("[data-deco-page]"),el=all.length?all[all.length-1]:null; + if(!el||!el.getAttribute("data-path-template")){el=document.querySelector("script[data-deco-studio-bridge]")||s} + return{t:el.getAttribute("data-path-template")||"",id:el.getAttribute("data-page-id")||""}; +} +document.addEventListener("keydown",function(e){ + var t=e.composedPath?e.composedPath()[0]:e.target; + if(e.defaultPrevented||(t&&(t.tagName==="INPUT"||t.tagName==="TEXTAREA"||t.tagName==="SELECT"||t.isContentEditable)))return; + if(e.key!=="."&&!(e.ctrlKey&&e.shiftKey&&e.key==="E"))return; + e.preventDefault(); + e.stopPropagation(); + var g=page(),h=new URL("https://studio.decocms.com/choose-editor"); + h.searchParams.set("site",site); + h.searchParams.set("domain",location.origin); + if(g.id)h.searchParams.set("pageId",g.id); + h.searchParams.set("path",location.pathname+location.search); + h.searchParams.set("pathTemplate",g.t||"/*"); + if((e.ctrlKey||e.metaKey)&&e.key==="."){w.open(h.toString(),"_blank");return} + location.href=h.toString(); +}); +})();`; + +/** The "." shortcut's script. Render it once, in the ``. */ +export function StudioShortcut({ + site, + editShortcut = true, + page, + nonce, +}: StudioShortcutProps): ReactNode { + return createElement("script", { + "data-deco-studio-bridge": "", + "data-site": site, + "data-shortcut": editShortcut ? "" : undefined, + // Read on each keypress, so a client-side navigation that re-renders the + // script with the next page updates them. + "data-path-template": page?.pathTemplate || undefined, + "data-page-id": page?.id || undefined, + nonce, + // Browsers hide a nonce from the DOM after parsing, which React would + // report as a hydration mismatch. + suppressHydrationWarning: true, + dangerouslySetInnerHTML: { __html: STUDIO_SHORTCUT_SCRIPT }, + }); +} diff --git a/tests/studio-shortcut.test.ts b/tests/studio-shortcut.test.ts new file mode 100644 index 00000000..97309626 --- /dev/null +++ b/tests/studio-shortcut.test.ts @@ -0,0 +1,233 @@ +/** + * The Studio pieces both bindings render (`StudioBridge` of `@decocms/nextjs`, + * `DecoDocument` / `DecoPreview` of `@decocms/tanstack`, whose own test, + * react.test.tsx, checks it mounts exactly these): v8 only, with v7's "." + * edit shortcut as a script of its own. + * + * - Their markup is what a test runner that proxies the site serves as is: + * Cypress rewrites "frame-busting" code in the HTML it proxies + * (`modifyObstructiveCode`), and a rewrite inside a script that a React + * Server Components payload also carries changes that payload's length, so + * the page fails to hydrate ("Connection closed."). The regexes below are + * Cypress's own (`packages/rewriter/lib/regex-rewriter.ts`, `strip()`), in + * both of its modes. + * - The two bindings carry the same shortcut file (neither imports the other). + * - The shortcut: "." opens the page in Studio's editor only when the page + * isn't framed, and never while the visitor types in a field. + */ +import fs from "node:fs"; +import { StudioChannel } from "@decocms/blocks/preview"; +import { StudioBridge } from "@decocms/nextjs"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { STUDIO_BRIDGE_SCRIPT } from "../packages/blocks/src/v8/preview/StudioBridge.ts"; +import * as nextShortcut from "../packages/nextjs/src/studioShortcut.ts"; +import * as tanstackShortcut from "../packages/tanstack/src/studioShortcut.ts"; + +// Cypress's regex rewriter (packages/rewriter/lib/regex-rewriter.ts). +const topOrParentEqualityBeforeRe = + /((?:\bwindow\b|\bself\b)(?:\.|\[['"](?:top|self)['"]\])?\s*[!=]==?\s*(?:(?:window|self)(?:\.|\[['"]))?)(top|parent)(?![\w])/g; +const topOrParentEqualityAfterRe = /(top|parent)((?:["']\])?\s*[!=]==?\s*(?:\bwindow\b|\bself\b))/g; +const topOrParentExpandedEqualityBeforeRe = + /((?:\bwindow\b|\bself\b|\b[a-zA-z]\.\b)(?:\.|\[['"](?:top|self)['"]\])?\s*[!=]==?\s*(?:(?:window|self|[a-zA-z])(?:\.|\[['"]))?)(top|parent)(?![\w])/g; +const topOrParentExpandedEqualityAfterRe = + /(top|parent)((?:["']\])?\s*[!=]==?\s*(?:\bwindow\b|\b(?:[a-zA-z]\.)?self\b))/g; +const topWindowLocationRe = /(top)(\.window\.location\s?=)/g; +const topOrParentLocationOrFramesRe = + /([^\da-zA-Z()])?(\btop\b|\bparent\b)([.])(\blocation\b|\bframes\b)/g; +const jiraTopWindowGetterRe = + /(!function\s*\((\w{1})\)\s*{\s*return\s*\w{1}\s*(?:={2,})\s*\w{1}\.parent)(\s*}\(\w{1}\))/g; +const jiraTopWindowGetterUnMinifiedRe = + /(function\s*\w{1,}\s*\((\w{1})\)\s*{\s*return\s*\w{1}\s*(?:={2,})\s*\w{1}\.parent)(\s*;\s*})/g; +const baseTagTargetRe = + /(]*?)\s+target\s*=\s*(?:"_(?:top|parent)"|'_(?:top|parent)'|_(?:top|parent)([\s/>]))/gi; + +/** Cypress's `strip()` without the integrity rules (the pages here carry no SRI hash). */ +function cypressStrip(html: string, thirdParty: boolean): string { + return html + .replace( + thirdParty ? topOrParentExpandedEqualityBeforeRe : topOrParentEqualityBeforeRe, + "$1self", + ) + .replace(thirdParty ? topOrParentExpandedEqualityAfterRe : topOrParentEqualityAfterRe, "self$2") + .replace(topOrParentLocationOrFramesRe, "$1self$3$4") + .replace(jiraTopWindowGetterRe, "$1 || $2.parent.__Cypress__$3") + .replace(jiraTopWindowGetterUnMinifiedRe, "$1 || $2.parent.__Cypress__$3") + .replace(topWindowLocationRe, "$1.window.self.location =") + .replace(baseTagTargetRe, "$1$2"); +} + +function expectUntouched(html: string): void { + expect(cypressStrip(html, false)).toBe(html); + expect(cypressStrip(html, true)).toBe(html); +} + +describe("what a test runner's frame-busting rewrite matches", () => { + it("catches v7's framed handler (the check works)", () => { + expect(cypressStrip(STUDIO_BRIDGE_SCRIPT, false)).not.toBe(STUDIO_BRIDGE_SCRIPT); + }); + + it("matches nothing in @decocms/nextjs's StudioBridge", () => { + const html = renderToStaticMarkup( + createElement(StudioBridge, { + site: "acme", + nonce: "n1", + page: { pathTemplate: "/:slug/p", id: "pages-pdp" }, + }), + ); + expect(html).toContain("data-deco-studio-channel"); + expect(html).toContain("data-shortcut"); + expect(html).not.toContain("scrollToComponent"); + expect(html).not.toContain("editor::rerender"); + expectUntouched(html); + }); + + it("matches nothing in @decocms/tanstack's pieces: the shortcut and the channel DecoDocument mounts", () => { + const html = + renderToStaticMarkup( + createElement(tanstackShortcut.StudioShortcut, { + site: "acme", + nonce: "n1", + page: { pathTemplate: "/:slug/p" }, + }), + ) + + renderToStaticMarkup( + createElement(StudioChannel, { + studioOrigins: ["https://studio.decocms.com", "https://studio-stg.decocms.com"], + nonce: "n1", + }), + ); + expect(html).toContain("data-shortcut"); + expectUntouched(html); + }); +}); + +it("both bindings carry the same shortcut file", () => { + const read = (p: string) => fs.readFileSync(new URL(p, import.meta.url), "utf8"); + expect(read("../packages/tanstack/src/studioShortcut.ts")).toBe( + read("../packages/nextjs/src/studioShortcut.ts"), + ); + expect(tanstackShortcut.STUDIO_SHORTCUT_SCRIPT).toBe(nextShortcut.STUDIO_SHORTCUT_SCRIPT); +}); + +describe.each([ + ["@decocms/nextjs", nextShortcut.StudioShortcut], + ["@decocms/tanstack", tanstackShortcut.StudioShortcut], +])("the edit shortcut of %s", (_name, Shortcut) => { + let cleanup: (() => void) | undefined; + afterEach(() => { + cleanup?.(); + cleanup = undefined; + vi.restoreAllMocks(); + }); + + /** Renders the script into the page and runs it as a browser would, framed or not. */ + function mount(props: nextShortcut.StudioShortcutProps, framed = false): void { + const host = document.body.appendChild(document.createElement("div")); + host.innerHTML = renderToStaticMarkup(createElement(Shortcut, props)); + const script = host.querySelector("script") as HTMLScriptElement; + const realParent = window.parent; + if (framed) Object.defineProperty(window, "parent", { value: {}, configurable: true }); + const added: [string, EventListener][] = []; + const docAdd = document.addEventListener.bind(document); + const spy = vi.spyOn(document, "addEventListener").mockImplementation((t, l, o) => { + added.push([t, l as EventListener]); + docAdd(t, l, o); + }); + Object.defineProperty(document, "currentScript", { value: script, configurable: true }); + try { + new Function(script.textContent ?? "")(); + } finally { + Object.defineProperty(document, "currentScript", { value: null, configurable: true }); + spy.mockRestore(); + } + cleanup = () => { + for (const [t, l] of added) document.removeEventListener(t, l); + Object.defineProperty(window, "parent", { value: realParent, configurable: true }); + delete (window as { __decoStudioShortcut?: boolean }).__decoStudioShortcut; + host.remove(); + }; + } + + function press(init: KeyboardEventInit, target: EventTarget = document.body): KeyboardEvent { + const e = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + composed: true, + ...init, + }); + target.dispatchEvent(e); + return e; + } + + function opened(open: { mock: { calls: unknown[][] } }, call = 0): Record { + const url = new URL(open.mock.calls[call][0] as string); + expect(url.origin + url.pathname).toBe("https://studio.decocms.com/choose-editor"); + expect(open.mock.calls[call][1]).toBe("_blank"); + return Object.fromEntries(url.searchParams); + } + + it("Ctrl+. opens the page in Studio's editor chooser, in a new tab, with the page it was given", () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + mount({ site: "shop", page: { pathTemplate: "/:slug/p", id: "pages-pdp" } }); + expect(press({ key: ".", ctrlKey: true }).defaultPrevented).toBe(true); + expect(opened(open)).toEqual({ + site: "shop", + domain: window.location.origin, + pageId: "pages-pdp", + path: window.location.pathname + window.location.search, + pathTemplate: "/:slug/p", + }); + }); + + it('a plain "." and Ctrl+Shift+E on the page itself (not framed) open the editor', () => { + mount({ site: "shop" }); + expect(press({ key: "." }).defaultPrevented).toBe(true); + expect(press({ key: "E", ctrlKey: true, shiftKey: true }).defaultPrevented).toBe(true); + }); + + it("is never active in a frame", () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + mount({ site: "shop" }, true); + expect(press({ key: ".", ctrlKey: true }).defaultPrevented).toBe(false); + expect(press({ key: "." }).defaultPrevented).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); + + it("an explicit false turns it off", () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + mount({ site: "shop", editShortcut: false }); + expect(press({ key: ".", ctrlKey: true }).defaultPrevented).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); + + it("leaves typing in a field alone, also in a field inside a shadow root", () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + mount({ site: "shop" }); + const input = document.body.appendChild(document.createElement("input")); + expect(press({ key: "." }, input).defaultPrevented).toBe(false); + input.remove(); + const shadowHost = document.body.appendChild(document.createElement("div")); + const inner = document.createElement("input"); + shadowHost.attachShadow({ mode: "open" }).appendChild(inner); + expect(press({ key: "." }, inner).defaultPrevented).toBe(false); + expect(open).not.toHaveBeenCalled(); + expect(press({ key: ".", ctrlKey: true }, shadowHost).defaultPrevented).toBe(true); + shadowHost.remove(); + }); + + it("a StudioPage marker on the page wins, read at the keypress", () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + mount({ site: "shop", page: { pathTemplate: "/from-layout" } }); + const meta = document.head.appendChild(document.createElement("meta")); + meta.setAttribute("data-deco-page", ""); + meta.setAttribute("data-path-template", "/s/:term"); + meta.setAttribute("data-page-id", "pages-search"); + press({ key: ".", ctrlKey: true }); + expect(opened(open, 0)).toMatchObject({ pathTemplate: "/s/:term", pageId: "pages-search" }); + meta.remove(); + press({ key: ".", ctrlKey: true }); + expect(opened(open, 1)).toMatchObject({ pathTemplate: "/from-layout" }); + }); +});