From 2def5879d28f5c6c628ff812519aea588793fd80 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:07:22 -0300 Subject: [PATCH] feat(web): v8 variant previews through the draft pointer v8 variant previews: forced variants go through the draft pointer URL instead of the matcher override. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../blocks/blocks-preview-workspace-state.ts | 8 +- .../sandbox/preview/cms-controls.test.ts | 45 +++++- .../sandbox/preview/cms-controls.ts | 16 +++ .../use-fast-preview-draft-url.ts | 15 +- .../variant-draft-pointer.test.ts | 119 ++++++++++++++++ .../sections-editor/variant-draft-pointer.ts | 133 ++++++++++++++++++ 6 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/sections-editor/variant-draft-pointer.test.ts create mode 100644 apps/web/src/components/sections-editor/variant-draft-pointer.ts diff --git a/apps/web/src/components/sandbox/blocks/blocks-preview-workspace-state.ts b/apps/web/src/components/sandbox/blocks/blocks-preview-workspace-state.ts index b0327d94d4..2f49e929ce 100644 --- a/apps/web/src/components/sandbox/blocks/blocks-preview-workspace-state.ts +++ b/apps/web/src/components/sandbox/blocks/blocks-preview-workspace-state.ts @@ -7,9 +7,11 @@ export interface BlocksPreviewWorkspaceState { target: BlocksTarget | null; editSeoPageKey: string | null; /** - * `x-deco-matchers-override` params published by the Blocks panel so the - * (independent) Preview iframe renders the variant currently selected in the - * sections editor. `null` clears any prior override. + * Published by the Blocks panel so the (independent) Preview iframe renders + * the variant currently selected in the sections editor: + * `x-deco-matchers-override` params on a legacy site, forced variants + * (`@=`) for the `?__draft=` pointer on a + * content-protocol site. `null` clears any prior override. */ variantOverride: string[] | null; /** diff --git a/apps/web/src/components/sandbox/preview/cms-controls.test.ts b/apps/web/src/components/sandbox/preview/cms-controls.test.ts index 3db9373ded..442283a429 100644 --- a/apps/web/src/components/sandbox/preview/cms-controls.test.ts +++ b/apps/web/src/components/sandbox/preview/cms-controls.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { showCmsPageSelector } from "./cms-controls"; +import { showCmsPageSelector, showPreviewToolbarFor } from "./cms-controls"; describe("showCmsPageSelector", () => { it("shows it whenever content editing and the preview toolbar are enabled", () => { @@ -29,3 +29,46 @@ describe("showCmsPageSelector", () => { ).toBe(false); }); }); + +describe("showPreviewToolbarFor", () => { + const base = { + previewSurfaceActive: true, + daemonReady: false, + production: false, + servePreviewUrl: null, + }; + + it("shows it for a deco serve preview without a sandbox daemon", () => { + expect( + showPreviewToolbarFor({ + ...base, + servePreviewUrl: "http://localhost:5180", + }), + ).toBe(true); + expect( + showCmsPageSelector({ + showPreviewToolbar: showPreviewToolbarFor({ + ...base, + servePreviewUrl: "http://localhost:5180", + }), + contentEditingEnabled: true, + }), + ).toBe(true); + }); + + it("shows it for a ready daemon or production", () => { + expect(showPreviewToolbarFor({ ...base, daemonReady: true })).toBe(true); + expect(showPreviewToolbarFor({ ...base, production: true })).toBe(true); + }); + + it("hides it with nothing servable or no preview surface", () => { + expect(showPreviewToolbarFor(base)).toBe(false); + expect( + showPreviewToolbarFor({ + ...base, + previewSurfaceActive: false, + servePreviewUrl: "http://localhost:5180", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/sandbox/preview/cms-controls.ts b/apps/web/src/components/sandbox/preview/cms-controls.ts index 58aabef799..920e542112 100644 --- a/apps/web/src/components/sandbox/preview/cms-controls.ts +++ b/apps/web/src/components/sandbox/preview/cms-controls.ts @@ -1,3 +1,19 @@ +/** The preview toolbar (URL bar, page picker) shows once something is + * servable: a ready sandbox daemon, production, or a connected `deco serve`'s + * app (`deco serve --preview`, v8), which never has a sandbox claim. A v7 + * Local tunnel keeps the toolbar it had before: the daemon's. */ +export function showPreviewToolbarFor(input: { + previewSurfaceActive: boolean; + daemonReady: boolean; + production: boolean; + servePreviewUrl: string | null | undefined; +}): boolean { + return ( + input.previewSurfaceActive && + (input.daemonReady || input.production || !!input.servePreviewUrl) + ); +} + /** The page selector uses the exact same product gate as Content and Blocks. */ export function showCmsPageSelector(input: { showPreviewToolbar: boolean; diff --git a/apps/web/src/components/sections-editor/use-fast-preview-draft-url.ts b/apps/web/src/components/sections-editor/use-fast-preview-draft-url.ts index ba1fb0aaea..bc6688a9e2 100644 --- a/apps/web/src/components/sections-editor/use-fast-preview-draft-url.ts +++ b/apps/web/src/components/sections-editor/use-fast-preview-draft-url.ts @@ -1,5 +1,7 @@ import { buildDraftPointer, withDraftPointer } from "./section-preview-url"; import { useDecofileDraft } from "./decofile-api"; +import { useProtocolDraft } from "./content-protocol-api"; +import { useContentBackend } from "./use-content-backend"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; interface DraftParams { @@ -10,7 +12,9 @@ interface DraftParams { /** * This session's `?__draft=` pointer, or `null` when Fast Preview is off or no - * decofile read/write has stashed a grant yet (KEYS.decofileDraft). + * pointer is ready yet: a v7 site's decofile read/write stashes a grant + * (KEYS.decofileDraft); a hosted v8 site points at its draft on the + * delivery CDN ({@link useProtocolDraft}). * * The Fast Preview gate is load-bearing: a coding session shares the CMS * draft's branch, and the grant cache never expires, so without it a @@ -23,10 +27,13 @@ interface DraftParams { export function useDraftPointer(params: DraftParams | null): string | null { const fastPreviewActive = useSessionRuntime(params?.virtualMcpId).runtime === "cms"; + const backend = useContentBackend(params?.virtualMcpId, params?.branch); + const github = backend.kind === "protocol" && backend.source === "github"; const draft = useDecofileDraft(params); - return params && draft && fastPreviewActive - ? buildDraftPointer({ ...params, ...draft }) - : null; + const protocolPointer = useProtocolDraft(github ? params : null); + if (!params || !fastPreviewActive) return null; + if (github) return protocolPointer; + return draft ? buildDraftPointer({ ...params, ...draft }) : null; } export interface FastPreviewDraftUrl { diff --git a/apps/web/src/components/sections-editor/variant-draft-pointer.test.ts b/apps/web/src/components/sections-editor/variant-draft-pointer.test.ts new file mode 100644 index 0000000000..a97b170adb --- /dev/null +++ b/apps/web/src/components/sections-editor/variant-draft-pointer.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "bun:test"; +import { + buildPageForcedVariants, + buildSectionForcedVariants, + buildServeDraftPointer, + withForcedVariants, + withForcedVariantsInDraftUrl, +} from "./variant-draft-pointer"; + +const rule = { __resolveType: "website/matchers/always.ts" }; +const plainPage = { multivariate: false, index: 0, variants: [] }; +const mvPage = { multivariate: true, index: 1, variants: [{ rule }, { rule }] }; +const mvObj = { variants: [{ rule }, { rule }, { rule }] }; + +describe("buildPageForcedVariants", () => { + it("forces the page's sections multivariate to the active page variant", () => { + expect(buildPageForcedVariants("Home", mvPage)).toEqual([ + "Home@sections=1", + ]); + }); + + it("forces nothing on a page without variants", () => { + expect(buildPageForcedVariants("Home", plainPage)).toEqual([]); + expect( + buildPageForcedVariants("Home", { ...mvPage, variants: [{ rule }] }), + ).toEqual([]); + }); +}); + +describe("buildSectionForcedVariants", () => { + const base = { + pageKey: "Home", + page: plainPage, + sectionIndex: 3, + sectionLazy: false, + mvObj, + selectedVariantIndex: 2, + }; + + it("addresses the section by its JSON path in the page", () => { + expect(buildSectionForcedVariants(base)).toEqual(["Home@sections.3=2"]); + }); + + it("goes through the page variant and the Lazy wrapper", () => { + expect( + buildSectionForcedVariants({ ...base, page: mvPage, sectionLazy: true }), + ).toEqual(["Home@sections.variants.1.value.3.section=2"]); + }); + + it("forces nothing for a variant that doesn't exist", () => { + expect( + buildSectionForcedVariants({ ...base, selectedVariantIndex: 3 }), + ).toEqual([]); + expect(buildSectionForcedVariants({ ...base, sectionIndex: -1 })).toEqual( + [], + ); + }); +}); + +describe("withForcedVariants", () => { + const pointer = "api.example/api/acme/decofile/vm/main?token=t.k-1@abc123"; + + it("adds each as an encoded __variant parameter before the version", () => { + expect( + withForcedVariants(pointer, ["Home (2)@sections.3=1", "Header@=0"]), + ).toBe( + "api.example/api/acme/decofile/vm/main?token=t.k-1" + + "&__variant=Home%20%282%29%40sections.3%3D1&__variant=Header%40%3D0@abc123", + ); + }); + + it("replaces the ones it already carried, and drops the query when none are left", () => { + const forced = withForcedVariants("localhost:4547/@local", ["A@b=1"]); + expect(forced).toBe("localhost:4547/?__variant=A%40b%3D1@local"); + expect(withForcedVariants(forced, ["A@b=2"])).toBe( + "localhost:4547/?__variant=A%40b%3D2@local", + ); + expect(withForcedVariants(forced, [])).toBe("localhost:4547/@local"); + }); +}); + +describe("buildServeDraftPointer", () => { + it("points at the deco serve endpoint, without the token", () => { + expect( + buildServeDraftPointer("http://127.0.0.1:4547/", ["Home@sections=1"]), + ).toBe("127.0.0.1:4547/?__variant=Home%40sections%3D1@local"); + expect(buildServeDraftPointer("http://[::1]:4547/rpc", ["A@=0"])).toBe( + "localhost:4547/rpc?__variant=A%40%3D0@local", + ); + }); + + it("is null with nothing forced", () => { + expect(buildServeDraftPointer("http://localhost:4547/", [])).toBeNull(); + }); +}); + +describe("withForcedVariantsInDraftUrl", () => { + it("rewrites the URL's draft pointer", () => { + const url = new URL( + withForcedVariantsInDraftUrl( + "https://site.example/p?__draft=api.example%2Fd%3Ftoken%3Dt%40v1&x=1", + ["Home@sections=1"], + ), + ); + expect(url.searchParams.get("__draft")).toBe( + "api.example/d?token=t&__variant=Home%40sections%3D1@v1", + ); + expect(url.searchParams.get("x")).toBe("1"); + }); + + it("leaves a URL without a pointer, or asking for the published render", () => { + for (const href of [ + "https://site.example/p", + "https://site.example/p?__draft=off", + ]) { + expect(withForcedVariantsInDraftUrl(href, ["A@=1"])).toBe(href); + } + }); +}); diff --git a/apps/web/src/components/sections-editor/variant-draft-pointer.ts b/apps/web/src/components/sections-editor/variant-draft-pointer.ts new file mode 100644 index 0000000000..4d089f7617 --- /dev/null +++ b/apps/web/src/components/sections-editor/variant-draft-pointer.ts @@ -0,0 +1,133 @@ +import type { PageVariantInfo } from "./variant-matcher-override"; + +/** + * Variant previews on a Blocks (v8) site, which has no + * `x-deco-matchers-override`: the preview's `?__draft=` pointer forces the + * variant instead. Each forced variant is one reserved `__variant` parameter + * in the pointer's query, `@=` URL-encoded: the saved + * block holding the multivariate, the JSON path to it inside that block, and + * the variant to show. `cms.forDraft` applies them even over the content + * module, which has no drafts (blocks docs: /next/releases-and-drafts#preview-a-variant). + */ +const FORCED_VARIANT_PARAM = "__variant="; + +/** `@=`: one forced variant, as the pointer carries it (before encoding). */ +function forcedVariant(block: string, path: string, index: number): string { + return `${block}@${path}=${index}`; +} + +/** Force the active *page* variant: the multivariate is the page's `sections`. */ +export function buildPageForcedVariants( + pageKey: string, + page: PageVariantInfo, +): string[] { + if (!pageKey || !page.multivariate || page.variants.length <= 1) return []; + if (page.index < 0 || page.index >= page.variants.length) return []; + return [forcedVariant(pageKey, "sections", page.index)]; +} + +/** + * Force the selected *section* variant. The path is the stored JSON path to + * the multivariate: inside the active page variant's list when the page has + * variants, and inside the Lazy wrapper's `section` when it has one. + */ +export function buildSectionForcedVariants(args: { + pageKey: string; + page: PageVariantInfo; + sectionIndex: number; + sectionLazy: boolean; + mvObj: Record; + selectedVariantIndex: number; +}): string[] { + const variants = Array.isArray(args.mvObj.variants) + ? args.mvObj.variants + : []; + if (!args.pageKey || args.sectionIndex < 0) return []; + if ( + args.selectedVariantIndex < 0 || + args.selectedVariantIndex >= variants.length + ) { + return []; + } + const base = args.page.multivariate + ? `sections.variants.${args.page.index}.value` + : "sections"; + const path = `${base}.${args.sectionIndex}${args.sectionLazy ? ".section" : ""}`; + return [forcedVariant(args.pageKey, path, args.selectedVariantIndex)]; +} + +/** encodeURIComponent, plus the characters it leaves that a pointer's path doesn't allow. */ +function encode(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +/** + * The pointer (`@`) with exactly these forced + * variants: any it already carried are replaced. + */ +export function withForcedVariants(pointer: string, forced: string[]): string { + const at = pointer.lastIndexOf("@"); + if (at <= 0) return pointer; + const location = pointer.slice(0, at); + const q = location.indexOf("?"); + const base = q === -1 ? location : location.slice(0, q); + const kept = + q === -1 + ? [] + : location + .slice(q + 1) + .split("&") + .filter((param) => param && !param.startsWith(FORCED_VARIANT_PARAM)); + const params = [ + ...kept, + ...forced.map((entry) => FORCED_VARIANT_PARAM + encode(entry)), + ]; + const query = params.length > 0 ? `?${params.join("&")}` : ""; + return `${base}${query}${pointer.slice(at)}`; +} + +/** + * A pointer to a connected `deco serve` that forces these variants. The site + * reads its content module, which ignores the rest of the pointer; the token + * never goes in it. `null` when nothing is forced or the endpoint is no URL. + */ +export function buildServeDraftPointer( + endpoint: string, + forced: string[], +): string | null { + if (forced.length === 0) return null; + try { + const url = new URL(endpoint); + // A pointer's host is a DNS name; `[::1]` is the same machine. + const host = + url.hostname === "[::1]" + ? `localhost${url.port ? `:${url.port}` : ""}` + : url.host; + return withForcedVariants(`${host}${url.pathname}@local`, forced); + } catch { + return null; + } +} + +/** + * A site URL whose `?__draft=` pointer carries these forced variants. A URL + * without a pointer, or asking for the published render (`off`), stays as is. + */ +export function withForcedVariantsInDraftUrl( + href: string, + forced: string[], +): string { + if (forced.length === 0) return href; + try { + const url = new URL(href); + const pointer = url.searchParams.get("__draft"); + if (!pointer || pointer === "off") return href; + url.searchParams.set("__draft", withForcedVariants(pointer, forced)); + return url.toString(); + } catch { + return href; + } +}