Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
* (`<block>@<path>=<index>`) for the `?__draft=` pointer on a
* content-protocol site. `null` clears any prior override.
*/
variantOverride: string[] | null;
/**
Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/components/sandbox/preview/cms-controls.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
16 changes: 16 additions & 0 deletions apps/web/src/components/sandbox/preview/cms-controls.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
119 changes: 119 additions & 0 deletions apps/web/src/components/sections-editor/variant-draft-pointer.test.ts
Original file line number Diff line number Diff line change
@@ -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(["[email protected]=2"]);
});

it("goes through the page variant and the Lazy wrapper", () => {
expect(
buildSectionForcedVariants({ ...base, page: mvPage, sectionLazy: true }),
).toEqual(["[email protected]=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);
}
});
});
133 changes: 133 additions & 0 deletions apps/web/src/components/sections-editor/variant-draft-pointer.ts
Original file line number Diff line number Diff line change
@@ -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, `<block>@<path>=<index>` 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=";

/** `<block>@<path>=<index>`: 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<string, unknown>;
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 (`<host><path[?query]>@<version>`) 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;
}
}
Loading