Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .agents/skills/deco-v7-to-v8-migration/reference/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ v8 has no invoke endpoint. Every call the browser made through `/deco/invoke` be
- `website/functions/requestToParam.ts` in a string field becomes the page's route `param` (`/:slug`) the block reads itself.
- Register blocks under their v7 names only, or the site editor lists them twice (`57561bd`).

## Editing locally (`deco serve`)

v8 ships no dev hook for content changes; two pieces of template code make a save show up (recipes: the TanStack Start and Next.js guides, "Edit in the site editor"):

- **Take a new content module without re-running `src/cms.ts`.** On Vite, accept `../.deco/blocks.gen` in `cms.ts` and hand the new module to `createCMS` again (it adopts it for the same `.deco` root and returns the same instance). Re-running `cms.ts` instead leaves server functions holding the old module, and the first request after a save fails with `client is not a function`.
- **Reload open pages when `.deco/blocks.gen.ts` changes.** Only the server imports it, so Vite swaps it without touching the browser: a small Vite plugin (`apply: "serve"`, `hotUpdate`) sends `full-reload` to the client environment. Without it the site editor's preview keeps showing the old content until a manual reload.

## Telemetry and analytics

- Telemetry: set `OTEL_EXPORTER_OTLP_ENDPOINT` to the OTLP collector and the auth header as the `OTEL_EXPORTER_OTLP_HEADERS` secret in production; send nothing in dev or parity runs (`89eae7d`).
Expand Down
85 changes: 85 additions & 0 deletions packages/blocks/src/v8/__conformance__/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,91 @@ describe("AR-11 / AR-20 / AR-26 / RD-03 / CT-09 drafts", () => {
});
});

describe("AR-66 a draft pointer's forced variants (releases-and-drafts#preview-a-variant)", () => {
const flag = (n: number) => ({
rule: { __resolveType: "segment", n },
value: { __resolveType: "lazy", value: { __resolveType: "heavy", n } },
});
const content = (): Snapshot => ({
revision: "r1",
blocks: {
Home: {
__resolveType: "page",
name: "Home",
path: "/",
sections: [{ __resolveType: "multivariate", variants: [flag(0), flag(1), flag(2)] }],
},
Banner: {
__resolveType: "website/flags/multivariate.ts",
variants: [
{ rule: { __resolveType: "always" }, value: "spring" },
{ rule: { __resolveType: "never" }, value: "summer" },
],
},
},
});
const pointer = (...variants: { block: string; path: string; index: number }[]) =>
formatDraftPointer({ host: "localhost:4547", path: "/", version: "local", variants });

it("the content module has no drafts, yet forDraft applies them; no rule runs; the release is untouched", async () => {
const segment = vi.fn(({ n }: { n: number }) => n === 0);
const heavy = vi.fn(({ n }: { n: number }) => `variant ${n}`);
const cms = createCMS({ blocks: { segment, heavy }, content: content() });
const draft = cms.forDraft(pointer({ block: "Home", path: "sections.0", index: 2 }));
const [page] = await draft.resolve<{ sections: unknown[] }>("Home");
expect(page?.sections).toEqual(["variant 2"]);
expect(segment).not.toHaveBeenCalled();
expect(heavy).toHaveBeenCalledTimes(1);
const [release] = await cms.forRelease().resolve<{ sections: unknown[] }>("Home");
expect(release?.sections).toEqual(["variant 0"]);
expect(await draft.revision()).toBe("r1");
});

it("the documented example, as written", () => {
expect(
formatDraftPointer({
host: "localhost:4547",
path: "/",
version: "local",
variants: [{ block: "Home", path: "sections.3", index: 1 }],
}),
).toBe("localhost:4547/?__variant=Home%40sections.3%3D1@local");
});

it("a legacy multivariate saved on its own is addressed with an empty path", async () => {
const cms = createCMS({ blocks: docsBlocks(), content: content() });
const [value] = await cms
.forDraft(pointer({ block: "Banner", path: "", index: 1 }))
.resolve("Banner");
expect(value).toBe("summer");
});

it("a stale address renders the content as saved", async () => {
const cms = createCMS({ blocks: docsBlocks(), content: content() });
const [value, error] = await cms
.forDraft(pointer({ block: "Banner", path: "variants.0", index: 1 }))
.resolve("Banner");
expect([value, error]).toEqual(["spring", null]);
});

it("a loader gets the pointer without them, once for every variant of one draft", async () => {
const load = vi.fn(async (_pointer?: string | null) => content());
const cms = createCMS({ blocks: docsBlocks(), content: { load } });
const draft = (index: number) =>
formatDraftPointer({
host: "api.deco.example",
path: "/drafts/acme/main?token=t",
version: "9f3c1a",
variants: [{ block: "Banner", path: "", index }],
});
expect((await cms.forDraft(draft(1)).resolve("Banner"))[0]).toBe("summer");
expect((await cms.forDraft(draft(0)).resolve("Banner"))[0]).toBe("spring");
expect(load.mock.calls.filter(([p]) => p)).toEqual([
["api.deco.example/drafts/acme/main?token=t@9f3c1a"],
]);
});
});

describe("AR-12 forRevision", () => {
it("pins a served revision; an unknown revision reads the release", async () => {
const { loader, publish } = swappableLoader(docsSnapshot("rev-1"));
Expand Down
9 changes: 8 additions & 1 deletion packages/blocks/src/v8/cms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import { builtIns } from "./builtins/index.ts";
import { secretBlock } from "./builtins/secret.ts";
import { CMSClient } from "./client.ts";
import { ContentStore, isLoader, isSnapshot } from "./content.ts";
import { parseDraftPointer } from "./draft.ts";
import { clearGlobals, contentIdentity, fnv1a, readEnv } from "./identity.ts";
import { remoteLoader, resetRemoteLoaders } from "./remoteLoader.ts";
import { resolveDestination, setCurrentTelemetry, TelemetryPipeline } from "./telemetry.ts";
import type { Blocks, Client, CMS, CMSConfig, Loader, Snapshot } from "./types.ts";
import { forceVariants } from "./variants.ts";

const INSTANCE_PREFIX = "decocms.blocks.cms:";
const MIN_INTERVAL = 60_000;
Expand Down Expand Up @@ -73,8 +75,13 @@ class CMSInstance implements CMS {
);
}

/** The draft, with the variants the pointer forces (even over a source with no drafts). */
forDraft(pointer: string): Client {
return this.#client(() => this.#store.draft(pointer));
const variants = parseDraftPointer(pointer)?.variants;
if (variants === undefined) return this.#client(() => this.#store.draft(pointer));
return this.#client(() =>
this.#store.draft(pointer).then((snapshot) => forceVariants(snapshot, variants)),
);
}

forRevision(revision: string): Client {
Expand Down
15 changes: 11 additions & 4 deletions packages/blocks/src/v8/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ export class ContentStore {

/**
* The draft a pointer names. A snapshot has no drafts and ignores the
* pointer. A loader gets `load(pointer)` only for a pointer that parses;
* anything else is `LOADER_FAILED`, never a silent fallback to the release.
* pointer. A loader gets `load(pointer)` only for a pointer that parses,
* formatted again without its `__variant` parameters (so every variant of
* one draft shares one load); anything else is `LOADER_FAILED`, never a
* silent fallback to the release.
*/
draft(pointer: string): Promise<Snapshot> {
const source = this.#source;
Expand All @@ -80,10 +82,15 @@ export class ContentStore {
if (parsed === null) {
return Promise.reject(errors.loaderFailed(`invalid draft pointer "${truncate(pointer)}"`));
}
const key = formatDraftPointer(parsed);
// The draft itself, without the variants a preview forces: those apply per client.
const key = formatDraftPointer({
host: parsed.host,
path: parsed.path,
version: parsed.version,
});
const cached = this.#drafts.get(key);
if (cached !== undefined) return cached;
const pending = this.#load(source, pointer);
const pending = this.#load(source, key);
this.#drafts.set(key, pending);
pending.catch(() => this.#drafts.delete(key));
return pending;
Expand Down
92 changes: 92 additions & 0 deletions packages/blocks/src/v8/draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,98 @@ describe("formatDraftPointer", () => {
});
});

describe("forced variants (the __variant parameters)", () => {
const forced = encodeURIComponent("Home [email protected]=1");

it("lifts them out of the path into variants, keeping the other parameters", () => {
expect(parseDraftPointer(`localhost:4547/live?a=1&__variant=${forced}&b=2@local`)).toEqual({
host: "localhost:4547",
path: "/live?a=1&b=2",
version: "local",
variants: [{ block: "Home Page", path: "sections.3", index: 1 }],
});
});

it("drops the query when only __variant parameters remain; an empty path addresses the block", () => {
const own = encodeURIComponent("Flag@=2");
expect(parseDraftPointer(`localhost:4547/?__variant=${own}&__variant=${forced}@v`)).toEqual({
host: "localhost:4547",
path: "/",
version: "v",
variants: [
{ block: "Flag", path: "", index: 2 },
{ block: "Home Page", path: "sections.3", index: 1 },
],
});
});

it("splits on the last @ before the last =", () => {
const value = encodeURIComponent("a@[email protected]=0");
expect(parseDraftPointer(`h/?__variant=${value}@v`)?.variants).toEqual([
{ block: "a@b", path: "sections.0", index: 0 },
]);
});

const bad: [string, string][] = [
["no block", encodeURIComponent("@sections.1=0")],
["no @", encodeURIComponent("Home=0")],
["no index", encodeURIComponent("Home@sections")],
["a negative index", encodeURIComponent("Home@sections=-1")],
["a non-integer index", encodeURIComponent("Home@sections=1.5")],
["an index past 9999", encodeURIComponent("Home@sections=10000")],
["an empty path segment", encodeURIComponent("[email protected]=0")],
["bad percent-encoding", "Home%E0%A4%A@x=0"],
];
for (const [label, value] of bad) {
it(`a pointer with ${label} doesn't parse`, () => {
expect(parseDraftPointer(`h/x?__variant=${value}@v`)).toBeNull();
});
}

it("format appends them, encoded, and round-trips", () => {
const pointer = {
host: "api.deco.example",
path: "/drafts/acme/main?token=t",
version: "9f3c1a",
variants: [
{ block: "Home (copy)", path: "sections.variants.0.value.2", index: 1 },
{ block: "Header", path: "", index: 0 },
],
};
const raw = formatDraftPointer(pointer);
expect(raw).toBe(
"api.deco.example/drafts/acme/main?token=t" +
"&__variant=Home%20%28copy%29%40sections.variants.0.value.2%3D1" +
"&__variant=Header%40%3D0@9f3c1a",
);
expect(parseDraftPointer(raw)).toEqual(pointer);
expect(formatDraftPointer({ host: "h", path: "/", version: "v", variants: [] })).toBe("h/@v");
});

it("format throws on a variant that wouldn't parse back, or a path that already carries one", () => {
const base = { host: "h", path: "/x", version: "v" };
expect(() =>
formatDraftPointer({ ...base, variants: [{ block: "", path: "", index: 0 }] }),
).toThrow(TypeError);
expect(() =>
formatDraftPointer({ ...base, variants: [{ block: "B", path: "a..b", index: 0 }] }),
).toThrow(TypeError);
expect(() =>
formatDraftPointer({ ...base, variants: [{ block: "B", path: "", index: 1.5 }] }),
).toThrow(TypeError);
expect(() => formatDraftPointer({ ...base, path: `/x?__variant=${forced}` })).toThrow(
TypeError,
);
});

it("draftCookie stores a pointer with forced variants", () => {
const pointer = `localhost:4547/?__variant=${forced}@local`;
expect(
draftCookie(request(`https://s.example/?__draft=${encodeURIComponent(pointer)}`)),
).toContain(`${DRAFT_COOKIE}=${encodeURIComponent(pointer)};`);
});
});

function request(url: string, cookie?: string): Request {
return new Request(url, { headers: cookie ? { cookie } : {} });
}
Expand Down
81 changes: 75 additions & 6 deletions packages/blocks/src/v8/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
* Draft pointers (see /next/api-reference#draft-pointers): the string
* `<host[:port]><path[?query]>@<version>` that names a draft, and the two
* helpers that carry one from a `?__draft=` link into a cookie.
*
* The query's reserved `__variant` parameters are the variants a preview
* forces (`<block>@<path>=<index>`, URL-encoded, one per multivariate); the
* parser lifts them out of `path` into `variants`.
*/
import type { DraftPointer } from "./types.ts";
import type { DraftPointer, ForcedVariant } from "./types.ts";

/** The draft cookie's name, for frameworks whose cookie API has no Request (Next.js `cookies()`). */
export const DRAFT_COOKIE = "deco-draft";
Expand All @@ -18,6 +22,8 @@ const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
/** Rooted path with an optional query; no `@`, `#`, whitespace or scheme characters. */
const PATH_RE = /^\/[A-Za-z0-9/_.%~=&?-]*$/;
const MAX_POINTER_LENGTH = 4096;
const VARIANT_PARAM = "__variant=";
const INDEX_RE = /^(0|[1-9][0-9]{0,3})$/;

/**
* Parses a pointer, strictly: `null` on a scheme, a stray `@`, an unrooted
Expand All @@ -39,7 +45,12 @@ export function parseDraftPointer(raw: string | null | undefined): DraftPointer
if (!PATH_RE.test(path) || path.startsWith("//")) return null;

const host = normalizeHost(location.slice(0, slash));
return host === null ? null : { host, path, version };
if (host === null) return null;
const split = splitVariants(path);
if (split === null) return null;
return split.variants.length === 0
? { host, path, version }
: { host, path: split.path, version, variants: split.variants };
}

/**
Expand All @@ -48,12 +59,70 @@ export function parseDraftPointer(raw: string | null | undefined): DraftPointer
* bad pointer is caught where it's built rather than where it's loaded.
*/
export function formatDraftPointer(pointer: DraftPointer): string {
const raw = `${pointer.host}${pointer.path}@${pointer.version}`;
const parsed = parseDraftPointer(raw);
if (parsed === null || parsed.path !== pointer.path || parsed.version !== pointer.version) {
const variants = pointer.variants ?? [];
const params = variants.map((variant) => VARIANT_PARAM + encodeVariant(variant));
const path =
params.length === 0
? pointer.path
: `${pointer.path}${pointer.path.includes("?") ? "&" : "?"}${params.join("&")}`;
const parsed = parseDraftPointer(`${pointer.host}${path}@${pointer.version}`);
if (
parsed === null ||
parsed.path !== pointer.path ||
parsed.version !== pointer.version ||
JSON.stringify(parsed.variants ?? []) !== JSON.stringify(variants.map(plainVariant))
) {
throw new TypeError(`invalid draft pointer parts: ${JSON.stringify(pointer)}`);
}
return `${parsed.host}${parsed.path}@${parsed.version}`;
return `${parsed.host}${path}@${parsed.version}`;
}

/** The path without its `__variant` parameters, and the variants they force; `null` on a bad one. */
function splitVariants(path: string): { path: string; variants: ForcedVariant[] } | null {
const q = path.indexOf("?");
if (q === -1) return { path, variants: [] };
const kept: string[] = [];
const variants: ForcedVariant[] = [];
for (const param of path.slice(q + 1).split("&")) {
if (!param.startsWith(VARIANT_PARAM)) {
kept.push(param);
continue;
}
const variant = decodeVariant(param.slice(VARIANT_PARAM.length));
if (variant === null) return null;
variants.push(variant);
}
const base = path.slice(0, q);
return { path: kept.length === 0 ? base : `${base}?${kept.join("&")}`, variants };
}

/** `<block>@<path>=<index>`: the index after the last `=`, the block before the last `@`. */
function decodeVariant(encoded: string): ForcedVariant | null {
let raw: string;
try {
raw = decodeURIComponent(encoded);
} catch {
return null;
}
const eq = raw.lastIndexOf("=");
const at = raw.lastIndexOf("@", eq);
if (eq === -1 || at <= 0) return null;
const index = raw.slice(eq + 1);
const path = raw.slice(at + 1, eq);
if (!INDEX_RE.test(index) || (path !== "" && path.split(".").includes(""))) return null;
return { block: raw.slice(0, at), path, index: Number(index) };
}

function encodeVariant({ block, path, index }: ForcedVariant): string {
// encodeURIComponent leaves !'()* as they are; the pointer's path doesn't allow them.
return encodeURIComponent(`${block}@${path}=${index}`).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}

function plainVariant({ block, path, index }: ForcedVariant): ForcedVariant {
return { block, path, index };
}

function normalizeHost(authority: string): string | null {
Expand Down
Loading
Loading