From f25d72f659290983bc78bc08aa71f0e4885e6514 Mon Sep 17 00:00:00 2001 From: gimenes Date: Sat, 3 Oct 2026 21:29:11 -0300 Subject: [PATCH] feat(blocks): a draft pointer can force variants for a preview Studio's variant tabs preview variant N through the ?__draft= pointer instead of a request header. The pointer's query carries reserved __variant parameters, `@=` URL-encoded: the saved block that holds the multivariate, its JSON path inside that block, and the variant to show. - parseDraftPointer lifts them out of `path` into `variants`; formatDraftPointer appends them. A malformed one rejects the pointer. - cms.forDraft applies them to the snapshot it reads, even over a source with no drafts (the content module): each addressed multivariate keeps only the forced variant with rule `true`, so no rule runs. The snapshot is copied along the addressed paths only; a stale address is ignored. - Loaders get the pointer without them, so every variant of one draft shares one load and one cache entry. forRelease never forces anything. - Migration skill gotchas: the two template pieces local editing needs (cms.ts taking a new content module via import.meta.hot, and a Vite plugin reloading pages when .deco/blocks.gen.ts changes). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../reference/gotchas.md | 7 ++ .../blocks/src/v8/__conformance__/sdk.test.ts | 85 +++++++++++++++++ packages/blocks/src/v8/cms.ts | 9 +- packages/blocks/src/v8/content.ts | 15 ++- packages/blocks/src/v8/draft.test.ts | 92 +++++++++++++++++++ packages/blocks/src/v8/draft.ts | 81 ++++++++++++++-- packages/blocks/src/v8/types.ts | 15 ++- packages/blocks/src/v8/variants.test.ts | 89 ++++++++++++++++++ packages/blocks/src/v8/variants.ts | 61 ++++++++++++ 9 files changed, 442 insertions(+), 12 deletions(-) create mode 100644 packages/blocks/src/v8/variants.test.ts create mode 100644 packages/blocks/src/v8/variants.ts diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/gotchas.md b/.agents/skills/deco-v7-to-v8-migration/reference/gotchas.md index a80ddff0..5dfb86f5 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/gotchas.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/gotchas.md @@ -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`). diff --git a/packages/blocks/src/v8/__conformance__/sdk.test.ts b/packages/blocks/src/v8/__conformance__/sdk.test.ts index 8e844b47..3a16e3e1 100644 --- a/packages/blocks/src/v8/__conformance__/sdk.test.ts +++ b/packages/blocks/src/v8/__conformance__/sdk.test.ts @@ -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")); diff --git a/packages/blocks/src/v8/cms.ts b/packages/blocks/src/v8/cms.ts index ffc35cdc..8af08758 100644 --- a/packages/blocks/src/v8/cms.ts +++ b/packages/blocks/src/v8/cms.ts @@ -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; @@ -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 { diff --git a/packages/blocks/src/v8/content.ts b/packages/blocks/src/v8/content.ts index c2acd301..85630ccc 100644 --- a/packages/blocks/src/v8/content.ts +++ b/packages/blocks/src/v8/content.ts @@ -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 { const source = this.#source; @@ -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; diff --git a/packages/blocks/src/v8/draft.test.ts b/packages/blocks/src/v8/draft.test.ts index 07a1f48c..77a5f6b8 100644 --- a/packages/blocks/src/v8/draft.test.ts +++ b/packages/blocks/src/v8/draft.test.ts @@ -95,6 +95,98 @@ describe("formatDraftPointer", () => { }); }); +describe("forced variants (the __variant parameters)", () => { + const forced = encodeURIComponent("Home Page@sections.3=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@b@sections.0=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("Home@sections..1=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 } : {} }); } diff --git a/packages/blocks/src/v8/draft.ts b/packages/blocks/src/v8/draft.ts index 12d0a4d4..2746aca7 100644 --- a/packages/blocks/src/v8/draft.ts +++ b/packages/blocks/src/v8/draft.ts @@ -2,8 +2,12 @@ * Draft pointers (see /next/api-reference#draft-pointers): the string * `@` 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 (`@=`, 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"; @@ -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 @@ -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 }; } /** @@ -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 }; +} + +/** `@=`: 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 { diff --git a/packages/blocks/src/v8/types.ts b/packages/blocks/src/v8/types.ts index 1467177a..ff7d0323 100644 --- a/packages/blocks/src/v8/types.ts +++ b/packages/blocks/src/v8/types.ts @@ -47,10 +47,23 @@ export interface Loader { export interface DraftPointer { /** `host[:port]` of the content source that holds the draft. */ host: string; - /** Starts with `/`; opaque to the app. */ + /** Starts with `/`; opaque to the app. Never carries the `__variant` parameters. */ path: string; /** Opaque and immutable: the branch head or ETag. */ version: string; + /** The variants this preview forces (the query's `__variant` parameters); absent when none. */ + variants?: ForcedVariant[]; +} + +/** + * A variant a preview forces: the multivariate block at `path` (dot-separated + * keys and indexes, `""` for the saved block itself) inside the saved block + * `block` shows its variant `index` instead of evaluating rules. + */ +export interface ForcedVariant { + block: string; + path: string; + index: number; } // --------------------------------------------------------------------------- diff --git a/packages/blocks/src/v8/variants.test.ts b/packages/blocks/src/v8/variants.test.ts new file mode 100644 index 00000000..4312bf39 --- /dev/null +++ b/packages/blocks/src/v8/variants.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment node +/** Forced variants applied to a snapshot (releases-and-drafts#preview-a-variant). */ +import { describe, expect, it } from "vitest"; +import type { Snapshot } from "./types"; +import { forceVariants } from "./variants"; + +const rule = (type: string) => ({ __resolveType: type }); + +function snapshot(): Snapshot { + return { + revision: "r", + aliases: { "site/flags/Variants.ts": "multivariate" }, + blocks: { + Home: { + __resolveType: "page", + sections: [ + { __resolveType: "hero" }, + { + __resolveType: "multivariate", + variants: [ + { rule: rule("always"), value: { __resolveType: "lazy", value: "a" } }, + { rule: rule("never"), value: { __resolveType: "lazy", value: "b" } }, + ], + }, + ], + }, + Legacy: { + __resolveType: "website/flags/multivariate.ts", + variants: [ + { rule: rule("always"), value: ["x"] }, + { rule: rule("never"), value: ["y"] }, + ], + }, + Aliased: { + __resolveType: "site/flags/Variants.ts", + variants: [ + { rule: rule("always"), value: 1 }, + { rule: rule("never"), value: 2 }, + ], + }, + NotAFlag: { __resolveType: "carousel", variants: [{ rule: rule("never"), value: 1 }] }, + }, + }; +} + +describe("forceVariants", () => { + it("keeps only the forced variant, ruled true, and copies only along the path", () => { + const before = snapshot(); + const after = forceVariants(before, [{ block: "Home", path: "sections.1", index: 1 }]); + const home = after.blocks.Home as { sections: unknown[] }; + expect(home.sections[1]).toEqual({ + __resolveType: "multivariate", + variants: [{ rule: true, value: { __resolveType: "lazy", value: "b" } }], + }); + expect(home.sections[0]).toBe((before.blocks.Home as { sections: unknown[] }).sections[0]); + expect(after.blocks.Legacy).toBe(before.blocks.Legacy); + expect(before).toEqual(snapshot()); + expect(after.revision).toBe("r"); + }); + + it("addresses the saved block itself with an empty path, under a legacy or snapshot alias", () => { + const after = forceVariants(snapshot(), [ + { block: "Legacy", path: "", index: 1 }, + { block: "Aliased", path: "", index: 0 }, + ]); + expect(after.blocks.Legacy).toEqual({ + __resolveType: "website/flags/multivariate.ts", + variants: [{ rule: true, value: ["y"] }], + }); + expect((after.blocks.Aliased as { variants: unknown[] }).variants).toEqual([ + { rule: true, value: 1 }, + ]); + }); + + it("ignores an address that reaches no multivariate with that variant", () => { + const before = snapshot(); + for (const variant of [ + { block: "Missing", path: "", index: 0 }, + { block: "Home", path: "sections.9", index: 0 }, + { block: "Home", path: "sections.01", index: 0 }, + { block: "Home", path: "sections.0", index: 0 }, + { block: "Home", path: "sections.1", index: 5 }, + { block: "NotAFlag", path: "", index: 0 }, + ]) { + expect(forceVariants(before, [variant])).toBe(before); + } + expect(forceVariants(before, undefined)).toBe(before); + }); +}); diff --git a/packages/blocks/src/v8/variants.ts b/packages/blocks/src/v8/variants.ts new file mode 100644 index 00000000..4b10a6ce --- /dev/null +++ b/packages/blocks/src/v8/variants.ts @@ -0,0 +1,61 @@ +/** + * Forced variants (see /next/releases-and-drafts#preview-a-variant): the + * variants a draft pointer's `__variant` parameters name, applied to a + * snapshot for one preview. Each addressed multivariate keeps only the forced + * variant, with the rule `true`, so it shows that variant's value and no other + * rule runs. The snapshot is copied along the addressed paths only; the shared + * one is never touched. + * + * An address that doesn't reach a multivariate block with that variant (a + * renamed block, a moved section, an index past the end) is ignored, so a + * stale preview link renders the content as saved. + */ +import { LEGACY_ALIASES } from "./builtins/legacy.ts"; +import { isPlainObject, own } from "./json.ts"; +import type { ForcedVariant, Snapshot } from "./types.ts"; + +export function forceVariants( + snapshot: Snapshot, + variants: readonly ForcedVariant[] | undefined, +): Snapshot { + if (variants === undefined || variants.length === 0) return snapshot; + let blocks: Record | undefined; + for (const { block, path, index } of variants) { + const entry = own(blocks ?? snapshot.blocks, block); + if (entry === undefined) continue; + const keys = path === "" ? [] : path.split("."); + const next = forceAt(entry, keys, index, snapshot); + if (next === entry) continue; + blocks ??= { ...snapshot.blocks }; + blocks[block] = next; + } + return blocks === undefined ? snapshot : { ...snapshot, blocks }; +} + +function forceAt(node: unknown, keys: string[], index: number, snapshot: Snapshot): unknown { + if (keys.length === 0) return forceNode(node, index, snapshot); + const [key, ...rest] = keys as [string, ...string[]]; + if (Array.isArray(node)) { + const i = Number(key); + if (!Number.isInteger(i) || String(i) !== key || i < 0 || i >= node.length) return node; + const child = forceAt(node[i], rest, index, snapshot); + if (child === node[i]) return node; + const copy = node.slice(); + copy[i] = child; + return copy; + } + if (!isPlainObject(node) || !Object.hasOwn(node, key)) return node; + const child = forceAt(node[key], rest, index, snapshot); + return child === node[key] ? node : { ...node, [key]: child }; +} + +/** A multivariate block (by its own name or an alias) with only `variants[index]`, ruled `true`. */ +function forceNode(node: unknown, index: number, snapshot: Snapshot): unknown { + if (!isPlainObject(node) || typeof node.__resolveType !== "string") return node; + const type = node.__resolveType; + const canonical = own(snapshot.aliases, type) ?? own(LEGACY_ALIASES, type) ?? type; + if (canonical !== "multivariate" || !Array.isArray(node.variants)) return node; + const variant = node.variants[index]; + if (!isPlainObject(variant)) return node; + return { ...node, variants: [{ ...variant, rule: true }] }; +}