diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index e0054d2ca1..eeaa920ba2 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -58,4 +58,8 @@ ENV DATABASE_URL=file:/app/data/mesh.db # The CLI handles migrations automatically on startup # Use --skip-migrations if you want to manage migrations separately -CMD ["bun", "run", "deco", "--no-tui", "--no-local-mode"] +# Runs Studio's CLI by path, not through the `deco` bin name: another installed +# package may declare a `deco` bin too (@decocms/blocks does), and whichever +# wins `node_modules/.bin/deco` would start instead of Studio +# (src/cli/deco-bin.test.ts). +CMD ["bun", "run", "node_modules/decocms/dist/server/cli.js", "--no-tui", "--no-local-mode"] diff --git a/apps/api/package.json b/apps/api/package.json index 83f4c6f4e1..304f61eb85 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -67,6 +67,7 @@ "@better-auth/sso": "1.4.1", "@decocms/better-auth": "1.5.17", "@decocms/bindings": "workspace:*", + "@decocms/blocks": "8.1.0-next.7", "@decocms/mcp-utils": "workspace:*", "@decocms/runtime": "workspace:*", "@decocms/sandbox": "workspace:*", diff --git a/apps/api/scripts/smoke-tarball.ts b/apps/api/scripts/smoke-tarball.ts index 61123b9302..89edc321fa 100644 --- a/apps/api/scripts/smoke-tarball.ts +++ b/apps/api/scripts/smoke-tarball.ts @@ -25,7 +25,7 @@ */ import { $ } from "bun"; -import { mkdtemp, writeFile } from "fs/promises"; +import { mkdtemp, realpath, writeFile } from "fs/promises"; import { join } from "path"; import { tmpdir } from "os"; @@ -71,7 +71,25 @@ if (!(await Bun.file(clientIndex).exists())) { // eagerly during load, so a missing external crashes here before // --version prints — same symptom a real consumer would hit. const cliBin = join(scratch, "node_modules", ".bin", "deco"); +const studioCli = join( + scratch, + "node_modules", + "decocms", + "dist", + "server", + "cli.js", +); +// Another installed package with a `deco` bin (@decocms/blocks has one) can +// win the link and start instead of Studio: the bin must be Studio's CLI. +if ((await realpath(cliBin)) !== (await realpath(studioCli))) { + console.error( + `node_modules/.bin/deco resolves to ${await realpath(cliBin)}, not Studio's ${studioCli}`, + ); + process.exit(1); +} await $`${cliBin} --version`.cwd(scratch); +// What the Docker image runs (apps/api/Dockerfile CMD): the CLI by path. +await $`bun run node_modules/decocms/dist/server/cli.js --version`.cwd(scratch); console.log( "✅ Smoke test passed — browser assets are present and every external resolves at startup.", diff --git a/apps/api/src/api/routes/decofile.ts b/apps/api/src/api/routes/decofile.ts index 59bb521296..ebab5abd8e 100644 --- a/apps/api/src/api/routes/decofile.ts +++ b/apps/api/src/api/routes/decofile.ts @@ -8,6 +8,8 @@ * PATCH /api/:org/decofile/:virtualMcpId/:branch write blocks (session) * POST /api/:org/decofile/:virtualMcpId/:branch/publish merge into default (session) * GET /api/:org/decofile/:virtualMcpId/:branch/status drift vs default (session) + * POST /api/:org/decofile/:virtualMcpId/:branch/rpc content protocol (session, flag) + * GET /api/:org/decofile/:virtualMcpId/:branch/changes draft changes vs production (token or session, flag) * * The surface is inert unless the virtual MCP has both a preview server URL * (`previewServerUrl`, legacy `productionUrl`) and a GitHub repo — what a CMS @@ -15,6 +17,16 @@ * does NOT gate it; that switch only picks the runtime a NEW thread is stamped * with, and gating this on it would strand an already-stamped session. * + * The content-protocol routes serve next-major Blocks sites (see + * `decofile/repo-content-storage.ts`) and exist only behind the + * `site_editor_content_protocol` org flag. `rpc` doesn't need a preview + * server: the protocol never renders, and a project without one just has no + * preview. Their previews use the same Fast Preview pointer as v7, naming + * `changes` instead of the whole decofile: only what the branch changed + * against production (`decofile/draft-changes.ts`), computed on request. + * The editor gets that pointer's draft token and API host the v7 way, from + * the session-authenticated GET read above. + * * Anonymous access: `resolveOrgFromPath` lets unauthenticated requests through * (membership is only enforced for signed-in principals), so the GET handler * self-enforces the signed draft token, mirroring automation-webhooks.ts. @@ -45,8 +57,16 @@ import { type DecofilePatch, } from "@/decofile/commit-coalescer"; import { signDraftToken, verifyDraftToken } from "@/decofile/draft-token"; +import { + buildDraftChanges, + DraftChangesInvalidBlock, + DraftChangesTooLarge, +} from "@/decofile/draft-changes"; import { repoGitRebase } from "@/decofile/git-compat"; import { readDecofileSnapshot } from "@/decofile/read-decofile"; +import { createRepoContentStorage } from "@/decofile/repo-content-storage"; +import { createContentHandler } from "@decocms/blocks/protocol/server"; +import { orgFlagEnabled } from "@decocms/shared/organization/schema"; import { projectPlanningPostsForPreview } from "@/decofile/blog-draft-projection"; import { orgHasFeature } from "@/core/plan-feature-gate"; import type { Env } from "../hono-env"; @@ -59,6 +79,7 @@ interface DecofileScope { repository: RepositoryBinding; /** Present only for session-authenticated (member) requests. */ userId: string | null; + previewServerUrl: string | null; } type DecofileEnv = Env & { @@ -192,7 +213,7 @@ const resolveDecofileScope = createMiddleware(async (c, next) => { * threads, and gating the data plane on it would strand a session already * stamped `cms` the moment someone flips the switch off. */ const previewServerUrl = resolvePreviewServerUrl(metadata); - if (!previewServerUrl) { + if (!previewServerUrl && !isContentProtocolPath(c.req.path)) { return c.json({ error: "Project has no preview server configured" }, 404); } @@ -211,10 +232,46 @@ const resolveDecofileScope = createMiddleware(async (c, next) => { packagePath: runtime?.path?.replace(/^\/+|\/+$/g, "") || null, repository, userId, + previewServerUrl, }); return next(); }); +/** Content-protocol routes that work without a preview server. */ +function isContentProtocolPath(path: string): boolean { + return path.endsWith("/rpc"); +} + +/** + * The `site_editor_content_protocol` org flag, read before a content-protocol + * route does anything (and so before any commit). A failed read is "off": + * the route answers 404 and the editor stays on the v7 path. + */ +async function contentProtocolEnabled( + c: Context, +): Promise { + const ctx = c.var.studioContext; + const organizationId = c.get("decofileScope").organizationId; + try { + const settings = await ctx.storage.organizationSettings.get(organizationId); + return orgFlagEnabled(settings?.flags, "site_editor_content_protocol"); + } catch (error) { + console.error("decofile: org settings read failed; protocol off", { + organizationId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +function signScopeDraftToken(scope: DecofileScope): string { + return signDraftToken({ + organizationId: scope.organizationId, + virtualMcpId: scope.virtualMcpId, + branch: scope.branch, + }); +} + /** * The authority (host[:port]) the editor should bake into the `?__draft=` * pointer. `window.location.host` is wrong in the native app — the webview's @@ -300,11 +357,7 @@ export function createDecofileRoutes() { "content-type": "application/json", }); } - const token = signDraftToken({ - organizationId: scope.organizationId, - virtualMcpId: scope.virtualMcpId, - branch: scope.branch, - }); + const token = signScopeDraftToken(scope); return c.body( `{"version":${JSON.stringify(snapshot.sha)},"token":${JSON.stringify(token)},"apiHost":${JSON.stringify(requestApiHost(c))},"decofile":${snapshot.decofile}}`, 200, @@ -407,11 +460,7 @@ export function createDecofileRoutes() { }, patch, ); - const token = signDraftToken({ - organizationId: scope.organizationId, - virtualMcpId: scope.virtualMcpId, - branch: scope.branch, - }); + const token = signScopeDraftToken(scope); return c.json({ version: sha, token, apiHost: requestApiHost(c) }); } catch (err) { return errorResponse(c, err); @@ -496,5 +545,73 @@ export function createDecofileRoutes() { } }); + app.post("/:virtualMcpId/:branch/rpc", async (c) => { + if (!(await contentProtocolEnabled(c))) { + return c.json({ error: "Not found" }, 404); + } + const scope = c.get("decofileScope"); + const client = await contentClientForScope(c); + // Per request: the storage carries this caller's repository credential. + // The body cache is off for the same reason, and the blob cache under + // the storage already makes repeated reads cheap. + const handler = createContentHandler( + createRepoContentStorage({ + client, + packagePath: scope.packagePath, + branch: scope.branch, + coAuthor: coAuthorFromStudioContext(c.var.studioContext), + }), + { + server: { name: "studio-github", version: "1" }, + preview: scope.previewServerUrl + ? { url: new URL(scope.previewServerUrl).origin } + : null, + bodyCacheBytes: 0, + onError: (error) => + console.error("content protocol: internal error", { + organizationId: scope.organizationId, + virtualMcpId: scope.virtualMcpId, + error: error instanceof Error ? error.message : String(error), + }), + }, + ); + return handler(c.req.raw); + }); + + /** + * A content-protocol draft's `?__draft=` target: what the branch changed + * against production, never the whole decofile (blocks docs: + * /next/content-delivery#draft-previews). The site's SDK reads it with the + * pointer's token; a branch that doesn't exist yet changed nothing. + */ + app.get("/:virtualMcpId/:branch/changes", async (c) => { + const headers = { + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + }; + if (!(await contentProtocolEnabled(c))) { + return c.json({ error: "Not found" }, 404, headers); + } + const scope = c.get("decofileScope"); + try { + const changes = await buildDraftChanges( + await contentClientForScope(c), + scope.packagePath, + scope.branch, + ); + return c.json(changes, 200, headers); + } catch (err) { + if (err instanceof DraftChangesTooLarge) { + return c.json({ error: err.message }, 413, headers); + } + if (err instanceof DraftChangesInvalidBlock) { + return c.json({ error: err.message, file: err.file }, 422, headers); + } + const res = errorResponse(c, err); + for (const [k, v] of Object.entries(headers)) res.headers.set(k, v); + return res; + } + }); + return app; } diff --git a/apps/api/src/cli/deco-bin.test.ts b/apps/api/src/cli/deco-bin.test.ts new file mode 100644 index 0000000000..bd5fd33836 --- /dev/null +++ b/apps/api/src/cli/deco-bin.test.ts @@ -0,0 +1,77 @@ +/** + * The published `decocms` package and its Docker image must start Studio's + * own CLI. `@decocms/blocks` also declares a `deco` bin: as a runtime + * dependency, `bun add decocms` linked whichever `deco` won into + * `node_modules/.bin`, and the image started the Blocks CLI + * (`unknown command "--no-tui"`). The server bundle inlines what it needs, so + * no runtime dependency may declare a `deco` bin, and the image runs the CLI + * by path. `scripts/smoke-tarball.ts` checks the same on the packed tarball. + */ + +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const API_ROOT = join(import.meta.dir, "..", ".."); + +interface PackageJson { + name: string; + bin?: string | Record; + dependencies?: Record; + optionalDependencies?: Record; +} + +function readPackage(dir: string): PackageJson { + return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); +} + +function binNames(pkg: PackageJson): string[] { + if (!pkg.bin) return []; + if (typeof pkg.bin === "string") return [pkg.name.split("/").pop()!]; + return Object.keys(pkg.bin); +} + +const api = readPackage(API_ROOT); +const runtimeDeps = { + ...api.dependencies, + ...api.optionalDependencies, +}; + +describe("the deco bin", () => { + test("is Studio's CLI", () => { + expect(api.name).toBe("decocms"); + expect(api.bin).toEqual({ deco: "./dist/server/cli.js" }); + }); + + test("@decocms/blocks is bundled, never a runtime dependency", () => { + expect(Object.keys(runtimeDeps)).not.toContain("@decocms/blocks"); + }); + + test("no installed runtime dependency declares another deco bin", () => { + const clashes = Object.keys(runtimeDeps).filter((name) => { + const dir = join(API_ROOT, "node_modules", name); + // Optional platform packages may be missing on this machine. + if (!existsSync(join(dir, "package.json"))) return false; + return binNames(readPackage(dir)).includes("deco"); + }); + expect(clashes).toEqual([]); + }); + + test("the Docker image runs the CLI by path", () => { + const dockerfile = readFileSync(join(API_ROOT, "Dockerfile"), "utf8"); + const cmd = dockerfile + .split("\n") + .filter((line) => line.startsWith("CMD ")) + .pop(); + expect(cmd).toBeDefined(); + const argv = JSON.parse(cmd!.slice("CMD ".length)) as string[]; + // `bun add` of the tarball installs it at node_modules/decocms. + const cliPath = join( + "node_modules", + api.name, + (api.bin as Record).deco!, + ); + expect(argv.slice(0, 3)).toEqual(["bun", "run", cliPath]); + expect(argv).toContain("--no-tui"); + }); +}); diff --git a/apps/api/src/decofile/commit-coalescer.ts b/apps/api/src/decofile/commit-coalescer.ts index e5b3df6a33..f0ba25411a 100644 --- a/apps/api/src/decofile/commit-coalescer.ts +++ b/apps/api/src/decofile/commit-coalescer.ts @@ -1,9 +1,5 @@ -import { - appendCoAuthorTrailer, - type CoAuthorIdentity, -} from "@decocms/sandbox/shared"; -import { blockKeyToFileStem, mergeBlocks } from "@decocms/shared/decofile"; -import { repoIdentityKey } from "@decocms/shared/git-providers"; +import type { CoAuthorIdentity } from "@decocms/sandbox/shared"; +import { blockKeyToFileStem } from "@decocms/shared/decofile"; import { exponentialBackoffWithJitter, sleep } from "@decocms/shared/std"; import { type FileChange, @@ -15,9 +11,9 @@ import { blockEntriesInTree, blocksDirPath, primeBlobCache, - resolveBlockContents, resolveOrCreateHead, } from "./read-decofile"; +import { decofileCommitMessage, regenerateGenArtifact } from "./gen-artifact"; /** * Per-(virtualMcpId, branch) commit coalescer. Autosaves arrive every ~700ms @@ -174,33 +170,24 @@ async function commitBatch(batch: Batch): Promise { if (writes.length === 0) return headSha; - // Repos that track the merged artifact get it regenerated in-commit; - // gitignored repos (the common case) never have the tree entry. - const genPath = packagePath - ? `${packagePath}/.deco/blocks.gen.json` - : ".deco/blocks.gen.json"; - if (tree.some((e) => e.type === "blob" && e.path === genPath)) { - const files = await resolveBlockContents( - client, - nextBlocks.values(), - blobMemo, - ); - const { decofile: genContent, skipped } = mergeBlocks(files); - if (skipped.length > 0) { - console.warn("decofile gen: dropped blocks that were not valid JSON", { - repo: repoIdentityKey(client.repo), - branch, - packagePath, - blocks: skipped.map((s) => s.key), - }); - } - writes.push({ path: genPath, content: genContent }); - } + const gen = await regenerateGenArtifact({ + client, + tree, + packagePath, + branch, + nextBlocks: nextBlocks.values(), + memo: blobMemo, + }); + if (gen) writes.push(gen); try { const { sha } = await client.commitFiles({ branch, - message: commitMessage(batch), + message: decofileCommitMessage( + [...batch.set.keys()], + [...batch.del], + batch.deps.coAuthor, + ), expectedHead: headSha, changes: writes, }); @@ -217,16 +204,3 @@ async function commitBatch(batch: Batch): Promise { } } } - -function commitMessage(batch: Batch): string { - const summarize = (keys: string[]): string => { - const shown = keys.slice(0, 3).join(", "); - return keys.length > 3 ? `${shown} (+${keys.length - 3} more)` : shown; - }; - const parts: string[] = []; - if (batch.set.size > 0) - parts.push(`update ${summarize([...batch.set.keys()])}`); - if (batch.del.size > 0) parts.push(`delete ${summarize([...batch.del])}`); - const subject = `chore(decofile): ${parts.join("; ")}`; - return appendCoAuthorTrailer(subject, batch.deps.coAuthor); -} diff --git a/apps/api/src/decofile/draft-changes-test-helpers.ts b/apps/api/src/decofile/draft-changes-test-helpers.ts new file mode 100644 index 0000000000..d5ddf9d5d9 --- /dev/null +++ b/apps/api/src/decofile/draft-changes-test-helpers.ts @@ -0,0 +1,45 @@ +/** A fake repository for draft-changes tests: branches named after their commits. */ +import type { RepoContentClient, TreeEntry } from "@/git-providers"; +import { gitBlobSha } from "./read-decofile"; + +/** Saved-block files by name, per commit. */ +type Commit = Record; + +/** + * A repository whose commits are listed by sha; each commit is also a branch + * of the same name, and the merge base is fixed per test. + */ +export function fakeRepo(input: { + commits: Record; + mergeBase: string; + packagePath?: string; +}) { + const blobs = new Map(); + const reads: string[] = []; + const prefix = input.packagePath + ? `${input.packagePath}/.deco/blocks/` + : ".deco/blocks/"; + const client = { + repo: { provider: "github", host: "github.com", path: "acme/site" }, + getDefaultBranch: async () => "main", + getBranch: async (name: string) => + input.commits[name] ? { sha: name } : null, + mergeBase: async () => input.mergeBase, + listDecofileEntries: async (treeish: string): Promise => { + const commit = input.commits[treeish]; + if (!commit) throw new Error(`unknown commit ${treeish}`); + return Object.entries(commit).map(([file, content]) => { + const sha = gitBlobSha(content); + blobs.set(sha, content); + return { path: `${prefix}${file}`, type: "blob", sha }; + }); + }, + readBlob: async (sha: string) => { + reads.push(sha); + const content = blobs.get(sha); + if (content === undefined) throw new Error(`unknown blob ${sha}`); + return content; + }, + } as unknown as RepoContentClient; + return { client, reads }; +} diff --git a/apps/api/src/decofile/draft-changes.test.ts b/apps/api/src/decofile/draft-changes.test.ts new file mode 100644 index 0000000000..c82a09cbf2 --- /dev/null +++ b/apps/api/src/decofile/draft-changes.test.ts @@ -0,0 +1,209 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { serializeBlock } from "@decocms/blocks/protocol"; +import { + buildDraftChanges, + clearDraftChangesCache, + DraftChangesInvalidBlock, + DraftChangesTooLarge, + MAX_DRAFT_CHANGE_BYTES, +} from "./draft-changes"; +import { fakeRepo } from "./draft-changes-test-helpers"; +import { gitBlobSha } from "./read-decofile"; + +beforeAll(() => { + // No disk blob cache: every body read goes through the fake client. + process.env.FAST_PREVIEW_CACHE_DIR = ""; +}); + +beforeEach(() => clearDraftChangesCache()); + +const block = (value: unknown) => serializeBlock(value); + +describe("buildDraftChanges", () => { + it("is the cumulative file-level difference from the merge base", async () => { + const { client } = fakeRepo({ + mergeBase: "base", + commits: { + base: { + "Header.json": block({ title: "old" }), + "Footer.json": block({ links: 1 }), + "OldPromotion.json": block({ on: true }), + }, + draft: { + "Header.json": block({ title: "new" }), + "Footer.json": block({ links: 1 }), + "Added.json": block({ fresh: true }), + }, + }, + }); + expect(await buildDraftChanges(client, null, "draft")).toEqual({ + format: 1, + set: { Added: { fresh: true }, Header: { title: "new" } }, + delete: ["OldPromotion"], + }); + }); + + it("never carries production changes the draft didn't make", async () => { + // Production edited Footer after the draft branched: the draft's copy is + // still the merge base's, so it inherits production, not the stale copy. + const { client } = fakeRepo({ + mergeBase: "base", + commits: { + base: { "Footer.json": block({ v: 1 }) }, + draft: { "Footer.json": block({ v: 1 }) }, + }, + }); + expect(await buildDraftChanges(client, null, "draft")).toEqual({ + format: 1, + set: {}, + delete: [], + }); + }); + + it("changed nothing while the branch doesn't exist yet", async () => { + const { client } = fakeRepo({ mergeBase: "base", commits: { base: {} } }); + expect(await buildDraftChanges(client, null, "missing")).toEqual({ + format: 1, + set: {}, + delete: [], + }); + }); + + it("reads only changed bodies", async () => { + const { client, reads } = fakeRepo({ + mergeBase: "base", + commits: { + base: { + "A.json": block({ a: 1 }), + "B.json": block({ b: 1 }), + "C.json": block({ c: 1 }), + }, + draft: { + "A.json": block({ a: 2 }), + "B.json": block({ b: 1 }), + "C.json": block({ c: 1 }), + }, + }, + }); + await buildDraftChanges(client, null, "draft"); + expect(reads).toEqual([gitBlobSha(block({ a: 2 }))]); + }); + + it("treats a respelled file of one name as a replacement, not a deletion", async () => { + const { client } = fakeRepo({ + mergeBase: "base", + commits: { + base: { "pages%2Fhome.json": block({ path: "/", v: 1 }) }, + draft: { "pages%2fhome.json": block({ path: "/", v: 2 }) }, + }, + }); + const changes = await buildDraftChanges(client, null, "draft"); + expect(Object.keys(changes.set)).toEqual(["pages/home"]); + expect(changes.delete).toEqual([]); + }); + + it("keeps an entry named __proto__ as an entry", async () => { + const { client } = fakeRepo({ + mergeBase: "base", + commits: { base: {}, draft: { "__proto__.json": block({ x: 1 }) } }, + }); + const changes = await buildDraftChanges(client, null, "draft"); + expect(Object.hasOwn(changes.set, "__proto__")).toBe(true); + }); + + it("reads the app root's saved blocks in a monorepo", async () => { + const { client } = fakeRepo({ + packagePath: "apps/store", + mergeBase: "base", + commits: { base: {}, draft: { "Hero.json": block({ x: 1 }) } }, + }); + const changes = await buildDraftChanges(client, "apps/store", "draft"); + expect(Object.keys(changes.set)).toEqual(["Hero"]); + }); + + it("refuses changes larger than the limit", async () => { + const big = "x".repeat(MAX_DRAFT_CHANGE_BYTES / 2 + 1); + const { client } = fakeRepo({ + mergeBase: "base", + commits: { + base: {}, + draft: { "A.json": block({ big }), "B.json": block({ big }) }, + }, + }); + await expect(buildDraftChanges(client, null, "draft")).rejects.toThrow( + DraftChangesTooLarge, + ); + }); + it("tombstones what a delete-only branch removed", async () => { + const { client } = fakeRepo({ + mergeBase: "base", + commits: { + base: { "Keep.json": block({ k: 1 }), "Gone.json": block({ g: 1 }) }, + draft: { "Keep.json": block({ k: 1 }) }, + }, + }); + expect(await buildDraftChanges(client, null, "draft")).toEqual({ + format: 1, + set: {}, + delete: ["Gone"], + }); + }); + + it("answers a rename as a delete plus a set", async () => { + const { client } = fakeRepo({ + mergeBase: "base", + commits: { + base: { "Old.json": block({ x: 1 }) }, + draft: { "New.json": block({ x: 1 }) }, + }, + }); + expect(await buildDraftChanges(client, null, "draft")).toEqual({ + format: 1, + set: { New: { x: 1 } }, + delete: ["Old"], + }); + }); + + it("sets the other spelling when the draft deletes the winning one", async () => { + // Two spellings of one name: the page-like one wins. Deleting it leaves + // the other as the name's entry, so the name is set, not deleted. + const { client } = fakeRepo({ + mergeBase: "base", + commits: { + base: { + "pages%2Fhome.json": block({ path: "/", v: 1 }), + "pages%2fhome.json": block({ v: 2 }), + }, + draft: { "pages%2fhome.json": block({ v: 2 }) }, + }, + }); + expect(await buildDraftChanges(client, null, "draft")).toEqual({ + format: 1, + set: { "pages/home": { v: 2 } }, + delete: [], + }); + }); + + it("names the file of a saved block that isn't valid JSON", async () => { + const { client } = fakeRepo({ + mergeBase: "base", + commits: { base: {}, draft: { "Broken.json": "{ not json" } }, + }); + const error = await buildDraftChanges(client, null, "draft").catch( + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(DraftChangesInvalidBlock); + expect((error as DraftChangesInvalidBlock).file).toBe("Broken.json"); + }); + + it("reuses a result for the same branch head", async () => { + const { client, reads } = fakeRepo({ + mergeBase: "base", + commits: { base: {}, draft: { "A.json": block({ a: 1 }) } }, + }); + const first = await buildDraftChanges(client, null, "draft"); + const again = await buildDraftChanges(client, null, "draft"); + expect(again).toEqual(first); + expect(reads).toHaveLength(1); + }); +}); diff --git a/apps/api/src/decofile/draft-changes.ts b/apps/api/src/decofile/draft-changes.ts new file mode 100644 index 0000000000..0a4d9a295a --- /dev/null +++ b/apps/api/src/decofile/draft-changes.ts @@ -0,0 +1,231 @@ +/** + * A content-protocol draft's preview, for the GitHub backend (blocks docs: + * /next/content-delivery#draft-previews). The site's `?__draft=` pointer + * names `GET …/:branch/changes`, which answers only what the branch changed + * since it left production, never a whole decofile: + * `{format: 1, set: {name: block JSON}, delete: [name]}`. The site layers it + * over the production content it already has. + * + * "Changed" is file-level draft wins: a file whose blob differs from the + * merge base with the production branch replaces production's entry whole; + * a file the draft deleted is a tombstone; every other file inherits + * production. Only changed bodies are read, through the blob cache. Nothing + * is prepared or stored: each request computes it from Git, and a result + * is reused for a short while per branch head (it can't change for one head + * except when production takes the branch in, which leaves nothing to show). + */ + +import { + entryHasPath, + fullyDecodeFileName, + isBlockFileName, + resolveSpellings, +} from "@decocms/blocks/protocol"; +import type { RepoContentClient } from "@/git-providers"; +import { + blockEntriesInTree, + blocksDirPath, + resolveBlockContents, +} from "./read-decofile"; + +/** Cumulative changed-block bytes one response may carry. */ +export const MAX_DRAFT_CHANGE_BYTES = 8 * 1024 * 1024; + +/** What a draft branch changed against production. */ +export interface DraftChanges { + format: 1; + set: Record; + delete: string[]; +} + +/** Thrown when a draft's changes exceed {@link MAX_DRAFT_CHANGE_BYTES}. */ +export class DraftChangesTooLarge extends Error {} + +/** Thrown when a saved block on the branch isn't valid JSON; names its file. */ +export class DraftChangesInvalidBlock extends Error { + constructor(readonly file: string) { + super( + `saved block "${file}" isn't valid JSON; fix or discard it to preview`, + ); + } +} + +function parseBlock(file: string, text: string): unknown { + try { + return JSON.parse(text); + } catch { + throw new DraftChangesInvalidBlock(file); + } +} + +interface BlockFile { + file: string; + sha: string; + /** Blob bytes, when the provider's tree reports them. */ + size?: number; +} + +interface Winner extends BlockFile { + name: string; +} + +/** Saved-block files at `treeish`, grouped by spelling (every alias of one name). */ +async function blockFiles( + client: RepoContentClient, + treeish: string, + packagePath: string | null, +): Promise> { + const tree = await client.listDecofileEntries(treeish, packagePath); + const groups = new Map(); + const prefix = `${blocksDirPath(packagePath)}/`; + for (const entry of blockEntriesInTree(tree, packagePath)) { + const file = entry.path.slice(prefix.length); + if (!isBlockFileName(file)) continue; + const key = fullyDecodeFileName(file).name; + const group = groups.get(key) ?? []; + group.push({ file, sha: entry.sha, size: entry.size }); + groups.set(key, group); + } + return groups; +} + +function sameGroup(a: BlockFile[] = [], b: BlockFile[] = []): boolean { + if (a.length !== b.length) return false; + const shas = new Map(a.map((f) => [f.file, f.sha])); + return b.every((f) => shas.get(f.file) === f.sha); +} + +/** The entry a spelling group resolves to, by the shared key rule. */ +async function winnerOf( + client: RepoContentClient, + group: BlockFile[] | undefined, + memo: Map, +): Promise { + if (!group?.length) return null; + // Only an aliased name needs bodies: the winner prefers a page-like entry. + const bodies = + group.length === 1 + ? [null] + : await resolveBlockContents( + client, + group.map((f) => ({ stem: f.file, sha: f.sha })), + memo, + ); + const candidates = group.map((f, i) => ({ + ...f, + hasPath: bodies[i] + ? entryHasPath(parseBlock(f.file, bodies[i].content)) + : false, + })); + const [resolved] = resolveSpellings(candidates).values(); + if (!resolved) return null; + return { + name: resolved.name, + file: resolved.winner.file, + sha: resolved.winner.sha, + size: resolved.winner.size, + }; +} + +/** + * What `branch` changed against the production branch, from their merge + * base. A branch that doesn't exist yet changed nothing. + */ +export async function buildDraftChanges( + client: RepoContentClient, + packagePath: string | null, + branch: string, +): Promise { + const head = await client.getBranch(branch); + if (!head) return { format: 1, set: {}, delete: [] }; + const { host, path } = client.repo; + const key = `${host}/${path}\0${packagePath ?? ""}\0${head.sha}`; + const now = Date.now(); + const cached = recent.get(key); + if (cached && cached.until > now) return cached.changes; + const changes = changesAt(client, packagePath, head.sha); + recent.delete(key); + recent.set(key, { until: now + REUSE_MS, changes }); + while (recent.size > REUSE_ENTRIES) { + recent.delete(recent.keys().next().value!); + } + changes.catch(() => { + if (recent.get(key)?.changes === changes) recent.delete(key); + }); + return changes; +} + +/** How long, and for how many heads, a computed result is reused. */ +const REUSE_MS = 60_000; +const REUSE_ENTRIES = 100; +const recent = new Map< + string, + { until: number; changes: Promise } +>(); + +/** Forgets reused results (tests). */ +export function clearDraftChangesCache(): void { + recent.clear(); +} + +async function changesAt( + client: RepoContentClient, + packagePath: string | null, + headSha: string, +): Promise { + const empty: DraftChanges = { format: 1, set: {}, delete: [] }; + const production = await client.getDefaultBranch(); + const mergeBaseSha = await client.mergeBase(production, headSha); + if (mergeBaseSha === headSha) return empty; + const [draft, base] = await Promise.all([ + blockFiles(client, headSha, packagePath), + blockFiles(client, mergeBaseSha, packagePath), + ]); + + const memo = new Map(); + const replaced: Winner[] = []; + const deleted = new Set(); + for (const key of new Set([...draft.keys(), ...base.keys()])) { + if (sameGroup(draft.get(key), base.get(key))) continue; + const [now, before] = await Promise.all([ + winnerOf(client, draft.get(key), memo), + winnerOf(client, base.get(key), memo), + ]); + if (now && before && now.file === before.file && now.sha === before.sha) { + continue; + } + if (before && before.name !== now?.name) deleted.add(before.name); + if (now) replaced.push(now); + } + + const tooLarge = () => + new DraftChangesTooLarge( + `draft changes exceed ${MAX_DRAFT_CHANGE_BYTES} bytes; publish or discard some of them to preview`, + ); + // Refuse from tree metadata before reading; the read below re-checks real bytes. + if ( + replaced.reduce((sum, w) => sum + (w.size ?? 0), 0) > MAX_DRAFT_CHANGE_BYTES + ) { + throw tooLarge(); + } + const bodies = await resolveBlockContents( + client, + replaced.map((w) => ({ stem: w.file, sha: w.sha })), + memo, + ); + let bytes = 0; + const set: Array<[string, unknown]> = []; + for (const [i, winner] of replaced.entries()) { + const text = bodies[i]!.content; + bytes += Buffer.byteLength(text); + if (bytes > MAX_DRAFT_CHANGE_BYTES) throw tooLarge(); + set.push([winner.name, parseBlock(winner.file, text)]); + deleted.delete(winner.name); + } + return { + format: 1, + // fromEntries: an entry named "__proto__" stays an entry. + set: Object.fromEntries(set), + delete: [...deleted].sort(), + }; +} diff --git a/apps/api/src/decofile/gen-artifact.ts b/apps/api/src/decofile/gen-artifact.ts new file mode 100644 index 0000000000..ed945e9043 --- /dev/null +++ b/apps/api/src/decofile/gen-artifact.ts @@ -0,0 +1,71 @@ +import { + appendCoAuthorTrailer, + type CoAuthorIdentity, +} from "@decocms/sandbox/shared"; +import { mergeBlocks } from "@decocms/shared/decofile"; +import { repoIdentityKey } from "@decocms/shared/git-providers"; +import type { FileChange, RepoContentClient, TreeEntry } from "@/git-providers"; +import { type BlockSource, resolveBlockContents } from "./read-decofile"; + +/** Repo-relative path of the merged `blocks.gen.json` artifact. */ +function genArtifactPath(packagePath: string | null): string { + return packagePath + ? `${packagePath}/.deco/blocks.gen.json` + : ".deco/blocks.gen.json"; +} + +/** + * The regenerated `blocks.gen.json`, for a commit that changes `.deco/blocks`. + * + * Repos that track the merged artifact get it rewritten in the same commit, so + * it never disagrees with the block files; gitignored repos (the common case) + * have no tree entry and get `null`. `nextBlocks` is the post-commit view of + * the blocks dir; `memo` is the blob memo a compare-and-swap retry loop + * threads through, so a retry never re-reads a blob it already resolved. + */ +export async function regenerateGenArtifact(params: { + client: RepoContentClient; + tree: TreeEntry[]; + packagePath: string | null; + branch: string; + nextBlocks: Iterable; + memo: Map; +}): Promise { + const { client, tree, packagePath, branch } = params; + const genPath = genArtifactPath(packagePath); + if (!tree.some((e) => e.type === "blob" && e.path === genPath)) return null; + const files = await resolveBlockContents( + client, + params.nextBlocks, + params.memo, + ); + const { decofile, skipped } = mergeBlocks(files); + if (skipped.length > 0) { + console.warn("decofile gen: dropped blocks that were not valid JSON", { + repo: repoIdentityKey(client.repo), + branch, + packagePath, + blocks: skipped.map((s) => s.key), + }); + } + return { path: genPath, content: decofile }; +} + +/** The commit message of a decofile write: what changed, plus the co-author. */ +export function decofileCommitMessage( + setKeys: string[], + deleteKeys: string[], + coAuthor: CoAuthorIdentity | null | undefined, +): string { + const summarize = (keys: string[]): string => { + const shown = keys.slice(0, 3).join(", "); + return keys.length > 3 ? `${shown} (+${keys.length - 3} more)` : shown; + }; + const parts: string[] = []; + if (setKeys.length > 0) parts.push(`update ${summarize(setKeys)}`); + if (deleteKeys.length > 0) parts.push(`delete ${summarize(deleteKeys)}`); + return appendCoAuthorTrailer( + `chore(decofile): ${parts.join("; ")}`, + coAuthor, + ); +} diff --git a/apps/api/src/decofile/legacy-secret-guard.test.ts b/apps/api/src/decofile/legacy-secret-guard.test.ts new file mode 100644 index 0000000000..397416a532 --- /dev/null +++ b/apps/api/src/decofile/legacy-secret-guard.test.ts @@ -0,0 +1,128 @@ +/** + * The content protocol's secret guard on a legacy (v7) site. + * + * A v7 `website/loaders/secret.ts` block marks its `encrypted` string + * `"format": "secret"`, but that string is the site's own hex ciphertext, not + * a v8 `Secret` field. The guard must let it through unchanged, and stay strict + * for v8 `Secret` fields and `secret` blocks. The published + * `@decocms/blocks@8.1.0-next.3` lacks the exemption, so Studio carries it as + * `patches/@decocms%2Fblocks@8.1.0-next.3.patch` until a release includes it. + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createContentHandler } from "@decocms/blocks/protocol/server"; +import { createFsStorage } from "@decocms/blocks/protocol/storage/fs"; + +const LOADER = "website/loaders/secret.ts"; + +/** A v7 `meta.gen.json` with the secret loader, an app that uses it, and a v8 section. */ +const meta = { + manifest: { + blocks: { + loaders: { [LOADER]: { $ref: "#/definitions/c2VjcmV0" } }, + apps: { "site/apps/site.ts": { $ref: "#/definitions/c2l0ZQ==" } }, + sections: { newsletter: { $ref: "#/definitions/bmV3c2xldHRlcg==" } }, + }, + }, + schema: { + definitions: { + c2VjcmV0: { + type: "object", + properties: { + name: { type: "string" }, + encrypted: { type: "string", format: "secret" }, + }, + }, + "c2l0ZQ==": { + type: "object", + properties: { apiKey: { $ref: "#/definitions/c2VjcmV0" } }, + }, + "bmV3c2xldHRlcg==": { + type: "object", + properties: { apiKey: { type: "string", format: "secret" } }, + }, + }, + }, +}; + +let root: string; +let handler: (request: Request) => Promise; + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "legacy-secret-guard-")); + await mkdir(join(root, ".deco", "blocks"), { recursive: true }); + await writeFile(join(root, ".deco", "meta.gen.json"), JSON.stringify(meta)); + handler = createContentHandler(createFsStorage({ root }), { + server: { name: "test", version: "0" }, + }); +}); + +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function apply(set: Record) { + const res = await handler( + new Request("http://localhost/rpc", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "blocks.apply", + params: { set }, + }), + }), + ); + return (await res.json()) as { + result?: { revision: string }; + error?: { code: number; message: string }; + }; +} + +describe("secret guard on a legacy site", () => { + test("saves a v7 secret loader block's hex ciphertext unchanged", async () => { + const body = await apply({ + site: { + __resolveType: "site/apps/site.ts", + apiKey: { + __resolveType: LOADER, + name: "API_KEY", + encrypted: "0a1b2c3d", + }, + }, + }); + expect(body.error).toBeUndefined(); + expect(body.result?.revision).toEqual(expect.any(String)); + }); + + test("still refuses plain text in a v8 Secret field", async () => { + const body = await apply({ + news: { __resolveType: "newsletter", apiKey: "plain-text" }, + }); + expect(body.error).toBeDefined(); + }); + + test("still refuses a malformed v8 secret block", async () => { + const body = await apply({ + news: { + __resolveType: "newsletter", + apiKey: { __resolveType: "secret", ciphertext: "0a1b2c3d" }, + }, + }); + expect(body.error).toBeDefined(); + }); + + test("still refuses a v7 loader block in a v8 Secret field", async () => { + const body = await apply({ + news: { + __resolveType: "newsletter", + apiKey: { __resolveType: LOADER, encrypted: "0a1b2c3d" }, + }, + }); + expect(body.error).toBeDefined(); + }); +}); diff --git a/apps/api/src/decofile/repo-content-storage.ts b/apps/api/src/decofile/repo-content-storage.ts new file mode 100644 index 0000000000..8f5687c45d --- /dev/null +++ b/apps/api/src/decofile/repo-content-storage.ts @@ -0,0 +1,298 @@ +/** + * The content protocol's storage over a project's Git repository: the site + * editor's GitHub backend (the hosted side of `deco serve`). + * + * One storage is bound to one `(repository, app root, branch)`. The vendored + * protocol core (`createContentHandler`) owns names, validation, the secret + * guard and retries; this file only lists, reads and commits files + * under `/.deco/`, through the same `RepoContentClient`, blob cache and + * compare-and-swap commit the legacy decofile routes use. + * + * Versions are git blob shas and the revision is the branch head sha. Before + * the first write a missing branch reads as the default branch (reported in + * `resolvedRef`); the first commit creates it at the head it was read from. + */ + +import type { CoAuthorIdentity } from "@decocms/sandbox/shared"; +import { + blockNameFromFile, + type CommitResult, + type ContentStorage, + type StorageDescription, + type StorageSnapshot, + type StoredFileBody, + type StoredSchema, + StorageNotFoundError, + StorageUnavailableError, + unsupported, +} from "@decocms/blocks/protocol"; +import { + type FileChange, + type RepoContentClient, + RepoWriteConflict, + repoRateLimitRetryAfterMs, + requireBranchHead, +} from "@/git-providers"; +import { decofileCommitMessage, regenerateGenArtifact } from "./gen-artifact"; +import { + type BlockSource, + blockEntriesInTree, + blocksDirPath, + gitBlobSha, + primeBlobCache, + resolveBlockContents, +} from "./read-decofile"; + +export interface RepoContentStorageOptions { + client: RepoContentClient; + /** The app root inside the repository; `null` at the repository root. */ + packagePath: string | null; + /** The one branch this storage reads and writes. */ + branch: string; + /** Acting user, appended as a `Co-authored-by:` trailer when present. */ + coAuthor?: CoAuthorIdentity | null; +} + +/** + * Whether committed schema text is a Blocks v8 one: only a top-level + * `"blocksMajor": 8` (written by `deco schema`) counts. Missing, any other + * value or unparseable text is a v7 site, which this protocol never serves. + * Mirrors the web's `isV8Schema`. + */ +function isV8SchemaText(text: string): boolean { + try { + const schema: unknown = JSON.parse(text); + return ( + typeof schema === "object" && + schema !== null && + !Array.isArray(schema) && + (schema as { blocksMajor?: unknown }).blocksMajor === 8 + ); + } catch { + return false; + } +} + +function decoPath(packagePath: string | null, file: string): string { + return packagePath ? `${packagePath}/.deco/${file}` : `.deco/${file}`; +} + +/** Turns provider failures into the storage errors the core understands. */ +async function guarded(work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + const retryAfterMs = repoRateLimitRetryAfterMs(error); + if (retryAfterMs !== undefined) { + throw new StorageUnavailableError( + "the Git provider is rate-limiting this repository", + retryAfterMs ?? undefined, + ); + } + throw error; + } +} + +export function createRepoContentStorage( + options: RepoContentStorageOptions, +): ContentStorage { + const { client, packagePath, branch } = options; + const blocksPrefix = `${blocksDirPath(packagePath)}/`; + + /** The bound branch's head, or the default branch's while it doesn't exist. */ + const readHead = async (): Promise<{ sha: string; ref: string }> => { + const head = await client.getBranch(branch); + if (head) return { sha: head.sha, ref: branch }; + const defaultBranch = await client.getDefaultBranch(); + return { + sha: await requireBranchHead(client, defaultBranch), + ref: defaultBranch, + }; + }; + + /** `schema.gen.json`, else `meta.gen.json`, at one commit. */ + const schemaEntryAt = async (sha: string) => { + const paths = [ + decoPath(packagePath, "schema.gen.json"), + decoPath(packagePath, "meta.gen.json"), + ]; + const entries = await client.getEntriesAtPaths(sha, paths); + for (const path of paths) { + const entry = entries.get(path); + if (entry?.type === "blob") return entry; + } + return null; + }; + + /** The schema the bound branch sees, falling back to the default branch's. */ + const resolveSchema = async (head: { sha: string; ref: string }) => { + const entry = await schemaEntryAt(head.sha); + if (entry) return { entry, ref: head.ref }; + const defaultBranch = await client.getDefaultBranch(); + if (defaultBranch === head.ref) return null; + const fallback = await schemaEntryAt( + await requireBranchHead(client, defaultBranch), + ); + return fallback ? { entry: fallback, ref: defaultBranch } : null; + }; + + const readBlobText = async (stem: string, sha: string): Promise => { + const [file] = await resolveBlockContents(client, [{ stem, sha }]); + return file!.content; + }; + + return { + describe(): StorageDescription { + const description = { + kind: "git" as const, + root: packagePath ?? ".", + readOnly: false, + // Uploads go to Studio's own file storage, never into the repository. + assets: null, + }; + return description; + }, + + snapshot: () => + guarded(async (): Promise => { + const head = await readHead(); + const tree = await client.listDecofileEntries(head.sha, packagePath); + if (tree.length === 0 && (await schemaEntryAt(head.sha)) === null) { + throw new StorageNotFoundError( + `no .deco folder in ${packagePath ?? "the repository root"}`, + ); + } + return { + revision: head.sha, + resolvedRef: head.ref, + files: blockEntriesInTree(tree, packagePath).map((entry) => ({ + file: entry.path.slice(blocksPrefix.length), + version: entry.sha, + size: entry.size, + })), + }; + }), + + readFiles: (snapshot, files) => + guarded(async () => { + const wanted = new Set(files); + const sources = snapshot.files.filter((f) => wanted.has(f.file)); + // A snapshot is a commit, so the bytes always match its versions. + const contents = await resolveBlockContents( + client, + sources.map((f) => ({ stem: f.file, sha: f.version })), + ); + const out: Record = {}; + sources.forEach((f, i) => { + out[f.file] = { text: contents[i]!.content, version: f.version }; + }); + return out; + }), + + readSchema: () => + guarded(async (): Promise => { + const schema = await resolveSchema(await readHead()); + if (!schema) return null; + const text = await readBlobText(schema.entry.path, schema.entry.sha); + // A v7 site reads as schemaless, so the editor stays on the classic one. + if (!isV8SchemaText(text)) return null; + return { version: schema.entry.sha, text, resolvedRef: schema.ref }; + }), + + readSecretsPublicKey: () => + guarded(async () => { + const head = await readHead(); + const path = decoPath(packagePath, "secrets.pub"); + const entry = (await client.getEntriesAtPaths(head.sha, [path])).get( + path, + ); + return entry?.type === "blob" + ? readBlobText(entry.path, entry.sha) + : null; + }), + + commit: (attempt) => + guarded(async (): Promise => { + const base = attempt.base.revision; + try { + // The whole head is the guard: any commit since `base` is stale, + // which covers every per-file expectation the core asks for. + const current = await client.getBranch(branch); + if (current === null) { + await client.createBranch(branch, base); + } else if (current.sha !== base) { + return { status: "stale" }; + } + // Never write a v7 site through the protocol, even by a direct call. + const schema = await resolveSchema({ sha: base, ref: branch }); + if ( + !schema || + !isV8SchemaText( + await readBlobText(schema.entry.path, schema.entry.sha), + ) + ) { + throw unsupported("not a Blocks v8 site"); + } + if (attempt.expectedSchemaVersion !== undefined) { + if (schema.entry.sha !== attempt.expectedSchemaVersion) { + return { status: "stale" }; + } + } + + const tree = await client.listDecofileEntries(base, packagePath); + const present = new Set(attempt.base.files.map((f) => f.file)); + const nextBlocks = new Map( + blockEntriesInTree(tree, packagePath).map((e) => [ + e.path, + { stem: e.stem, sha: e.sha }, + ]), + ); + const changes: FileChange[] = []; + const versions: Record = {}; + for (const [file, content] of Object.entries(attempt.put)) { + const path = `${blocksPrefix}${file}`; + changes.push({ path, content }); + nextBlocks.set(path, { + stem: file.slice(0, -".json".length), + content, + }); + versions[file] = gitBlobSha(content); + await primeBlobCache(client.repo, content); + } + for (const file of attempt.delete) { + if (!present.has(file)) continue; + const path = `${blocksPrefix}${file}`; + changes.push({ path, deleted: true }); + nextBlocks.delete(path); + } + if (changes.length === 0) { + return { status: "committed", revision: base, versions }; + } + const gen = await regenerateGenArtifact({ + client, + tree, + packagePath, + branch, + nextBlocks: nextBlocks.values(), + memo: new Map(), + }); + if (gen) changes.push(gen); + + const { sha } = await client.commitFiles({ + branch, + message: decofileCommitMessage( + Object.keys(attempt.put).map(blockNameFromFile), + attempt.delete.map(blockNameFromFile), + options.coAuthor, + ), + expectedHead: base, + changes, + }); + return { status: "committed", revision: sha, versions }; + } catch (error) { + if (error instanceof RepoWriteConflict) return { status: "stale" }; + throw error; + } + }), + }; +} diff --git a/apps/api/src/git-providers/bitbucket/content.ts b/apps/api/src/git-providers/bitbucket/content.ts index b08e583c0d..15ef9d8533 100644 --- a/apps/api/src/git-providers/bitbucket/content.ts +++ b/apps/api/src/git-providers/bitbucket/content.ts @@ -775,6 +775,10 @@ export class BitbucketContentClient implements RepoContentClient { return { aheadBy: ahead.length, behindBy: behind.length }; } + mergeBase(base: string, head: string): Promise { + return this.mergeBaseSha(base, head); + } + async compareDetailed( base: string, head: string, diff --git a/apps/api/src/git-providers/content.ts b/apps/api/src/git-providers/content.ts index de1db68842..754ae2de38 100644 --- a/apps/api/src/git-providers/content.ts +++ b/apps/api/src/git-providers/content.ts @@ -224,6 +224,11 @@ export interface RepoContentClient { aheadBy: number; behindBy: number; }>; + /** + * The commit `base` and `head` last shared, without the changed files or + * commits; empty when they share no history. + */ + mergeBase(base: string, head: string): Promise; compareDetailed( base: string, head: string, diff --git a/apps/api/src/git-providers/github/content.ts b/apps/api/src/git-providers/github/content.ts index 502264e89d..c0fa5b95fe 100644 --- a/apps/api/src/git-providers/github/content.ts +++ b/apps/api/src/git-providers/github/content.ts @@ -943,6 +943,15 @@ export class GithubContentClient implements RepoContentClient { return { aheadBy: json.ahead_by, behindBy: json.behind_by }; } + async mergeBase(base: string, head: string): Promise { + // One commit per page: the merge base comes without the files and commits. + const { json } = await this.call<{ merge_base_commit?: { sha?: string } }>( + "GET", + `${this.repoBase}/compare/${encodeRepoFilePath(base)}...${encodeRepoFilePath(head)}?per_page=1`, + ); + return json.merge_base_commit?.sha ?? ""; + } + async compareDetailed( base: string, head: string, diff --git a/apps/api/src/git-providers/gitlab/content.ts b/apps/api/src/git-providers/gitlab/content.ts index 9bdea8f3be..fb154b01b4 100644 --- a/apps/api/src/git-providers/gitlab/content.ts +++ b/apps/api/src/git-providers/gitlab/content.ts @@ -820,6 +820,10 @@ export class GitlabContentClient implements RepoContentClient { }; } + mergeBase(base: string, head: string): Promise { + return this.mergeBaseSha(base, head); + } + async compareDetailed( base: string, head: string, diff --git a/apps/web/ct/harness/stubs/use-content-backend.ts b/apps/web/ct/harness/stubs/use-content-backend.ts new file mode 100644 index 0000000000..7753ad6adb --- /dev/null +++ b/apps/web/ct/harness/stubs/use-content-backend.ts @@ -0,0 +1,16 @@ +/** + * CT stub for `sections-editor/use-content-backend`. + * + * The real hook picks a project's content backend from the org flag, the + * project's `deco serve` connection and a probe of its schema, all through + * `useProjectContext()`, which this harness doesn't mount. Field specs cover + * the editor as v7 sites see it, so every project reads as legacy here. + */ +import type { ContentBackend } from "@/components/sections-editor/content-backend"; + +export function useContentBackend( + _virtualMcpId: string | null | undefined, + _branch: string | null | undefined, +): ContentBackend { + return { kind: "legacy" }; +} diff --git a/apps/web/ct/harness/stubs/use-new-blocks-editor.ts b/apps/web/ct/harness/stubs/use-new-blocks-editor.ts index 724528754a..4a3e531b4e 100644 --- a/apps/web/ct/harness/stubs/use-new-blocks-editor.ts +++ b/apps/web/ct/harness/stubs/use-new-blocks-editor.ts @@ -1,13 +1,13 @@ /** * CT stub for `@/hooks/use-new-blocks-editor`. * - * The real hook reads the `new_blocks_editor` org flag through - * `useProjectContext()`, which needs the full app provider tree this harness - * doesn't mount. Specs pick the editor by writing {@link NEW_BLOCKS_EDITOR_KEY} + * The real hook gives v8 sites the new editor and v7 ones the + * `new_blocks_editor` org flag, reading the site's content backend and the org + * settings, which need the full app provider tree this harness doesn't mount. Specs pick the editor by writing {@link NEW_BLOCKS_EDITOR_KEY} * before mounting (absent reads as off, like an unset flag); harnesses flip it * mid-test with {@link setNewBlocksEditor}. */ -import { useSyncExternalStore } from "react"; +import { type ReactNode, useSyncExternalStore } from "react"; export const NEW_BLOCKS_EDITOR_KEY = "ct:new-blocks-editor"; @@ -30,3 +30,12 @@ export function setNewBlocksEditor(enabled: boolean): void { export function useNewBlocksEditor(): boolean { return useSyncExternalStore(subscribe, read); } + +export function useNewBlocksEditorState(): boolean | undefined { + return useNewBlocksEditor(); +} + +/** No site to detect here: the stored choice stands for every site. */ +export function NewBlocksEditorProvider({ children }: { children: ReactNode }) { + return children; +} diff --git a/apps/web/docs/component-architecture.md b/apps/web/docs/component-architecture.md index 85cfc120c2..1370381613 100644 --- a/apps/web/docs/component-architecture.md +++ b/apps/web/docs/component-architecture.md @@ -11,10 +11,15 @@ variables where callers need to override them through `className`. Only the redesigned blocks editor remains opt-in, per organization, through **Settings → General → New blocks editor** (the `new_blocks_editor` org flag, -off by default). Any member may switch it, through the basic-usage -`ORGANIZATION_BLOCKS_EDITOR_SET` tool; General shows plain members only that -switch. Components in the editor read -`useNewBlocksEditor()`. Switching it changes only the editor for every member; +off by default), and only on v7 sites: a v8 (content-protocol) site always +gets it, including on the org-less `/site-editor`. Any member may switch it, +through the basic-usage `ORGANIZATION_BLOCKS_EDITOR_SET` tool; General shows +plain members only that switch. Components in the editor read +`useNewBlocksEditor()`, which decides from the site's content backend +(`NewBlocksEditorProvider`, mounted by the Content and Preview tabs) and the +flag (`newBlocksEditorEnabled`); editor roots wait on +`useNewBlocksEditorState()` while the site's version is detected. Switching +it changes only the editor for every member; navigation, page headers, and other application screens keep the default UI. Editor browser tests opt in with `test.use({ newBlocksEditor: true })`, which sets the flag on the test's org. Component tests choose the editor through the diff --git a/apps/web/package.json b/apps/web/package.json index 2a83c9cc17..65267b9209 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@better-auth/sso": "1.6.11", "@daveyplate/better-auth-ui": "^3.2.7", "@decocms/bindings": "workspace:*", + "@decocms/blocks": "8.1.0-next.7", "@decocms/mcp-utils": "workspace:*", "@decocms/sandbox": "workspace:*", "@decocms/shared": "workspace:*", diff --git a/apps/web/playwright-ct.config.ts b/apps/web/playwright-ct.config.ts index a43a6d06d5..4a191216eb 100644 --- a/apps/web/playwright-ct.config.ts +++ b/apps/web/playwright-ct.config.ts @@ -80,6 +80,13 @@ export default defineConfig({ find: /^@\/hooks\/use-new-blocks-editor$/, replacement: stub("use-new-blocks-editor.ts"), }, + // The content backend reads the org flag and project via + // useProjectContext(); fields are tested as v7 (legacy) sites see them. + // Matches the fields' relative `../use-content-backend` import too. + { + find: /^.*\/use-content-backend$/, + replacement: stub("use-content-backend.ts"), + }, // Editor uploads PUT to the org filesystem via useProjectContext(), which a bare mount lacks. { find: /^@\/components\/markdown-editor\/use-file-upload$/, diff --git a/apps/web/src/components/chat-layout/index.tsx b/apps/web/src/components/chat-layout/index.tsx index dfc96c09a1..247d20137f 100644 --- a/apps/web/src/components/chat-layout/index.tsx +++ b/apps/web/src/components/chat-layout/index.tsx @@ -35,6 +35,9 @@ interface ChatLayoutProps extends ChatLayoutState, ChatLayoutActions { contentKey: string; contentNavigation?: ReactNode; contentActions?: ReactNode; + /** No chat behind this layout (the account-less `/site-editor`): the thread + * stays closed and nothing offers to open it. */ + threadless?: boolean; } interface ChatLayoutContextValue extends Omit { @@ -52,7 +55,7 @@ export function useChatLayout(): ChatLayoutState & ChatLayoutActions { /** Null outside a ChatLayout (e.g. the settings route), rather than throwing — * for callers that render in both. */ export function useOptionalChatLayout(): - | (ChatLayoutState & ChatLayoutActions) + | (ChatLayoutState & ChatLayoutActions & { threadless?: boolean }) | null { return use(ChatLayoutContext); } @@ -107,25 +110,27 @@ function ChatLayoutRoot({ children, ...layout }: ChatLayoutProps) { return ( - { - if (isMobile) { - void navigate({ - to: ".", - search: (prev) => ({ - ...prev, - ...mobileSurfaceSearch( - value.mobileSurface === "chat" ? "main" : "chat", - ), - }), - replace: true, - }); - } else { - layout.toggleThread(); - } - }} - /> + {!layout.threadless && ( + { + if (isMobile) { + void navigate({ + to: ".", + search: (prev) => ({ + ...prev, + ...mobileSurfaceSearch( + value.mobileSurface === "chat" ? "main" : "chat", + ), + }), + replace: true, + }); + } else { + layout.toggleThread(); + } + }} + /> + )} {isMobile ? (
) { + return {children}; +} + /** * Blanks the stream and task contexts for a subtree. * diff --git a/apps/web/src/components/chat/context.tsx b/apps/web/src/components/chat/context.tsx index ecaf637cc5..cbf9746da6 100644 --- a/apps/web/src/components/chat/context.tsx +++ b/apps/web/src/components/chat/context.tsx @@ -11,6 +11,7 @@ export { ChatContextProvider as ChatProvider, ChatPrefsProvider, ChatStreamValueProvider, + ChatTaskValueProvider, DetachedChatContext, ActiveTaskProvider, ThreadlessChatProvider, diff --git a/apps/web/src/components/layout/index.tsx b/apps/web/src/components/layout/index.tsx index beadceffe7..e9dd249b7c 100644 --- a/apps/web/src/components/layout/index.tsx +++ b/apps/web/src/components/layout/index.tsx @@ -37,9 +37,13 @@ function useLayout() { function LayoutRoot({ children, notice, + outsideOrg, }: { children: ReactNode; notice?: ReactNode; + /** Outside any org (`/site-editor`): no org apps to record, and the org + * rail only when `rail` (a signed-in person, whose orgs it lists). */ + outsideOrg?: { rail: boolean }; }) { const isMobile = useIsMobile(); @@ -56,8 +60,10 @@ function LayoutRoot({
{notice}
- {projectFirstNav && } - {!isMobile && projectFirstNav && } + {projectFirstNav && !outsideOrg && } + {!isMobile && projectFirstNav && (outsideOrg?.rail ?? true) && ( + + )} import("@/components/sections-editor/sections-editor").then((m) => ({ @@ -67,6 +72,7 @@ export function BlocksPanel({ previewUrl, } : null; + const contentBackend = useContentBackend(virtualMcpId, currentBranch); const decofile = useDecofile(fetchParams, { fetchEnabled: devServerReady }); const meta = useLiveMeta(fetchParams, { fetchEnabled: devServerReady }); const state = resolveBlocksTabState({ @@ -74,16 +80,41 @@ export function BlocksPanel({ decofile: toBlocksQueryState(decofile), meta: toBlocksQueryState(meta), hasEditableContent: hasEditableDecoContent(decofile.data, meta.data), - fastPreviewActive: useSessionRuntime(virtualMcpId).runtime === "cms", + fastPreviewActive: + useSessionRuntime(virtualMcpId).runtime === "cms" || + isProtocolProject(contentBackend), }); + // Which editor this site gets (v8: the new one) waits on the same + // detection, so neither editor shows and then swaps to the other. + const editorUndecided = useNewBlocksEditorState() === undefined; + const panel = (children: ReactNode) => (
{children}
); - if (state.kind === "loading") return panel(); + if (state.kind === "loading" || editorUndecided) { + return panel(); + } + // No schema yet: the page (or any block) as plain fields, never an error. + if (decofile.data && isNoSchemaMeta(meta.data)) { + const target = workspace.state.target; + const initialKey = + target?.kind === "page" || target?.kind === "section" ? target.key : null; + return panel( + , + ); + } if (state.kind === "empty") return panel(); if (state.kind === "error") { const retry = () => { 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/blocks/page-json-panel.tsx b/apps/web/src/components/sandbox/blocks/page-json-panel.tsx index 0ca4bec4bb..6b53ff7e91 100644 --- a/apps/web/src/components/sandbox/blocks/page-json-panel.tsx +++ b/apps/web/src/components/sandbox/blocks/page-json-panel.tsx @@ -11,7 +11,7 @@ import { useDebouncedSaveBlock } from "@/components/sections-editor/use-save-blo import { usePackagePath } from "@/components/sections-editor/use-package-path"; import { decoBlockFilePath } from "@/components/sections-editor/deco-block-key"; import { decoRepoPath } from "@/components/sections-editor/deco-repo-path"; -import { useNewBlocksEditor } from "@/hooks/use-new-blocks-editor"; +import { useNewBlocksEditorState } from "@/hooks/use-new-blocks-editor"; import { useT } from "@/i18n/use-t.ts"; /** @@ -40,7 +40,8 @@ export function PageJsonPanel({ ref?: Ref; }) { const t = useT(); - const compact = useNewBlocksEditor(); + const editorState = useNewBlocksEditorState(); + const compact = editorState === true; const packagePath = usePackagePath(virtualMcpId); const blockFilePath = decoRepoPath(packagePath, decoBlockFilePath(pageKey)); const { org } = useProjectContext(); @@ -66,7 +67,10 @@ export function PageJsonPanel({ }); const pageData = decofile.data?.[pageKey]; - const loading = decofile.data === undefined && decofile.isLoading; + // Waits for which editor this site gets too, so its header doesn't swap. + const loading = + (decofile.data === undefined && decofile.isLoading) || + editorState === undefined; const missing = decofile.data !== undefined && pageData === undefined; const readOnly = !currentBranch || missing || loading; const initialJson = diff --git a/apps/web/src/components/sandbox/content/app-editor.tsx b/apps/web/src/components/sandbox/content/app-editor.tsx index 13f4e3be63..ee0ea98d17 100644 --- a/apps/web/src/components/sandbox/content/app-editor.tsx +++ b/apps/web/src/components/sandbox/content/app-editor.tsx @@ -1,7 +1,7 @@ import { useOptionalChatTask } from "@/components/chat/chat-context"; import { Spinner } from "@decocms/ui/components/spinner.tsx"; import { ChevronRight } from "@untitledui/icons"; -import { useRef, useState } from "react"; +import { type ReactNode, useRef, useState } from "react"; import { toast } from "sonner"; import { cn } from "@decocms/ui/lib/utils.ts"; import { ScrollArea } from "@decocms/ui/components/scroll-area.tsx"; @@ -12,6 +12,7 @@ import type { LiveMeta } from "@/components/sections-editor/resolve-schema"; import type { SectionCatalogEntry } from "@/components/sections-editor/section-catalog"; import { createReferencedBlockSaver } from "@/components/sections-editor/save-referenced-block"; import { + type SaveGuard, useDebouncedSaveBlock, useSaveBlock, } from "@/components/sections-editor/use-save-block"; @@ -38,6 +39,8 @@ export function AppEditor({ excludeFields, schemaPending = false, previewBaseUrl = null, + notice, + saveGuard, }: { orgSlug: string; virtualMcpId: string; @@ -51,6 +54,10 @@ export function AppEditor({ excludeFields?: readonly string[]; schemaPending?: boolean; previewBaseUrl?: string | null; + /** Shown above the form (e.g. that the block is not saved yet). */ + notice?: ReactNode; + /** A version guard for this block's autosaves (e.g. create-only on the first save). */ + saveGuard?: SaveGuard; }) { const threadId = useOptionalChatTask()?.taskId ?? null; const t = useT(); @@ -58,6 +65,7 @@ export function AppEditor({ // back to the Fast Preview production deployment while the sandbox boots. const sectionPreviewBase = useSectionPreviewBase({ virtualMcpId, + branch, sandboxUrl: previewBaseUrl, }); const resolveType = @@ -68,11 +76,10 @@ export function AppEditor({ const title = titleOverride ?? (block ? appLabel(blockKey, block, meta) : blockKey); - const { save, flush, isPending } = useDebouncedSaveBlock({ - orgSlug, - virtualMcpId, - branch, - }); + const { save, flush, isPending } = useDebouncedSaveBlock( + { orgSlug, virtualMcpId, branch }, + { guard: saveGuard }, + ); const saveBlock = useSaveBlock({ orgSlug, virtualMcpId, branch }); const saveReferencedBlock = createReferencedBlockSaver((refKey, data) => save(refKey, data), @@ -212,6 +219,7 @@ export function AppEditor({
+ {notice} {hasEditableFields ? ( ; + meta: LiveMeta; +}) { + const t = useT(); + const queryClient = useQueryClient(); + const cacheKey = useDecofileCacheKey({ orgSlug, virtualMcpId, branch }); + const target = readCmsSettingsBlock(decofile); + /** + * The create-only guard: "create" while the block is absent (every save + * sends `ifMatch: { CMS: null }`), "off" once a guarded save lands or the + * block was there already. A refused save moves to "reread": saves queued + * behind it stay create-only (and fail), and the guard turns off only once + * the content has been read again, so the editor shows the block someone + * else saved (or explains one of another type) before any edit replaces it. + */ + const guardState = useRef<"create" | "reread" | "off">( + target.kind === "absent" ? "create" : "off", + ); + const [guard] = useState(() => ({ + ifMatch: (blockKey) => + blockKey === CMS_SETTINGS_BLOCK_KEY && guardState.current !== "off" + ? { [CMS_SETTINGS_BLOCK_KEY]: null } + : undefined, + onApplied: () => { + guardState.current = "off"; + }, + onRejected: () => { + if (guardState.current !== "create") return; + guardState.current = "reread"; + // A read merges around blocks still being saved, so it waits until this + // branch's writes (the refused one and any queued behind it) settle. + const writes = { + mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), + }; + const reread = () => { + if (queryClient.isMutating(writes) > 0) { + setTimeout(reread, 100); + return; + } + void queryClient + .invalidateQueries({ queryKey: KEYS.decofile(cacheKey) }) + .finally(() => { + guardState.current = "off"; + }); + }; + setTimeout(reread, 0); + }, + })); + if (target.kind === "conflict") { + return ( + + ); + } + return ( + +

{t("sandbox.cmsSettingsBlock.description")}

+ {target.kind === "absent" && ( +

+ + {t("sandbox.cmsSettingsBlock.defaultsNotice")} +

+ )} +
+ } + /> + ); +} diff --git a/apps/web/src/components/sandbox/content/cms-settings.test.ts b/apps/web/src/components/sandbox/content/cms-settings.test.ts new file mode 100644 index 0000000000..ae7f97b73d --- /dev/null +++ b/apps/web/src/components/sandbox/content/cms-settings.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "bun:test"; +import type { LiveMeta } from "@/components/sections-editor/resolve-schema"; +import { resolveAppEditorSchema } from "./app-editor-schema"; +import { + CMS_SETTINGS_BLOCK_KEY, + CMS_SETTINGS_RESOLVE_TYPE, + hasCmsSettingsType, + readCmsSettingsBlock, +} from "./cms-settings"; + +/** As `deco schema` writes it: the type in the `content` group. */ +const meta: LiveMeta = { + manifest: { + blocks: { + content: { + seo: { $ref: "#/definitions/c2Vv", namespace: "site" }, + "cms-settings": { + $ref: "#/definitions/Y21zLXNldHRpbmdz", + namespace: "deco", + }, + }, + }, + }, + schema: { + definitions: { + Y21zLXNldHRpbmdz: { + title: "cms-settings", + type: "object", + required: ["__resolveType"], + properties: { + __resolveType: { + type: "string", + enum: ["cms-settings"], + default: "cms-settings", + }, + preview: { + type: "object", + title: "Preview", + properties: { + hosts: { + type: "array", + title: "Hosts", + items: { type: "string" }, + }, + }, + }, + telemetry: { + type: "object", + title: "Telemetry", + properties: { + enabled: { type: "boolean", title: "Enabled", default: true }, + errorSampleRate: { type: "number", default: 0.05 }, + }, + }, + analytics: { + type: "object", + title: "Analytics", + properties: { + enabled: { type: "boolean", title: "Enabled", default: true }, + }, + }, + }, + }, + }, + }, +}; + +describe("cms-settings", () => { + it("is offered only when the schema describes the type", () => { + expect(hasCmsSettingsType(meta)).toBe(true); + expect(hasCmsSettingsType(undefined)).toBe(false); + // Published 8.1.0-next.3: a content group without it. + expect( + hasCmsSettingsType({ + manifest: { blocks: { content: { seo: { $ref: "#/x" } } } }, + schema: {}, + }), + ).toBe(false); + // A v7 meta. + expect( + hasCmsSettingsType({ + manifest: { blocks: { sections: { "site/sections/Hero.tsx": {} } } }, + schema: {}, + }), + ).toBe(false); + }); + + it("stands in for an absent block with its type only (defaults)", () => { + expect(readCmsSettingsBlock({})).toEqual({ + kind: "absent", + block: { __resolveType: CMS_SETTINGS_RESOLVE_TYPE }, + }); + expect(readCmsSettingsBlock({ [CMS_SETTINGS_BLOCK_KEY]: null }).kind).toBe( + "absent", + ); + }); + + it("edits the saved block as is, variants included", () => { + const block = { + __resolveType: "cms-settings", + preview: { hosts: ["staging.example.com"] }, + telemetry: { + __resolveType: "multivariate", + variants: [{ rule: { __resolveType: "always" }, value: {} }], + }, + }; + expect(readCmsSettingsBlock({ CMS: block })).toEqual({ + kind: "saved", + block, + }); + }); + + it("does not edit a block named CMS of another type", () => { + expect(readCmsSettingsBlock({ CMS: { __resolveType: "hero" } })).toEqual({ + kind: "conflict", + resolveType: "hero", + }); + expect(readCmsSettingsBlock({ CMS: "text" })).toEqual({ + kind: "conflict", + resolveType: "", + }); + }); + + it("resolves the form from the schema: the three sections", () => { + const schema = resolveAppEditorSchema(CMS_SETTINGS_RESOLVE_TYPE, meta); + expect(Object.keys(schema?.properties ?? {})).toEqual( + expect.arrayContaining(["preview", "telemetry", "analytics"]), + ); + }); +}); diff --git a/apps/web/src/components/sandbox/content/cms-settings.ts b/apps/web/src/components/sandbox/content/cms-settings.ts new file mode 100644 index 0000000000..8112a62720 --- /dev/null +++ b/apps/web/src/components/sandbox/content/cms-settings.ts @@ -0,0 +1,52 @@ +import type { LiveMeta } from "@/components/sections-editor/resolve-schema"; + +/** + * The CMS settings of a Blocks (v8) site: preview hosts, telemetry and + * analytics, in one optional saved block. Its name and type are the only two + * things the editor knows about it; the form comes from the schema, which + * describes the type. A site whose framework has no such type (published + * `@decocms/blocks` before it existed, or a v7 site) gets no Settings entry. + */ +export const CMS_SETTINGS_BLOCK_KEY = "CMS"; +export const CMS_SETTINGS_RESOLVE_TYPE = "cms-settings"; + +/** Whether the schema describes the `cms-settings` block type. */ +export function hasCmsSettingsType(meta: LiveMeta | undefined): boolean { + const groups = meta?.manifest?.blocks ?? {}; + return Object.values(groups).some( + (group) => !!group && Object.hasOwn(group, CMS_SETTINGS_RESOLVE_TYPE), + ); +} + +export type CmsSettingsBlock = + /** No `CMS` block yet: every setting is at its default; the first save creates it. */ + | { kind: "absent"; block: Record } + | { kind: "saved"; block: Record } + /** A block named `CMS` of another type (`deco check` warns about it). */ + | { kind: "conflict"; resolveType: string }; + +/** The `CMS` block to edit, or a stand-in holding only its type when absent. */ +export function readCmsSettingsBlock( + decofile: Record, +): CmsSettingsBlock { + const value = Object.hasOwn(decofile, CMS_SETTINGS_BLOCK_KEY) + ? decofile[CMS_SETTINGS_BLOCK_KEY] + : undefined; + if (value === undefined || value === null) { + return { + kind: "absent", + block: { __resolveType: CMS_SETTINGS_RESOLVE_TYPE }, + }; + } + const resolveType = + typeof value === "object" && !Array.isArray(value) + ? (value as Record).__resolveType + : undefined; + if (resolveType !== CMS_SETTINGS_RESOLVE_TYPE) { + return { + kind: "conflict", + resolveType: typeof resolveType === "string" ? resolveType : "", + }; + } + return { kind: "saved", block: value as Record }; +} diff --git a/apps/web/src/components/sandbox/content/collections-sidebar.tsx b/apps/web/src/components/sandbox/content/collections-sidebar.tsx index 597d64dbde..e9c864a6a2 100644 --- a/apps/web/src/components/sandbox/content/collections-sidebar.tsx +++ b/apps/web/src/components/sandbox/content/collections-sidebar.tsx @@ -28,19 +28,24 @@ import { useT } from "@/i18n/use-t.ts"; import type { CollectionCounts, CollectionId } from "./content-browser"; const ADVANCED_IDS = ["sections", "apps", "loaders", "actions"] as const; +const NONE_HIDDEN: readonly CollectionId[] = []; export function CollectionsSidebar({ active, counts, + hidden = NONE_HIDDEN, showBlog, onSelect, }: { active: CollectionId; counts: CollectionCounts; + /** Collections this site has no use for, left out of the list. */ + hidden?: readonly CollectionId[]; showBlog: boolean; onSelect: (id: CollectionId) => void; }) { const t = useT(); + const shown = (id: CollectionId) => !hidden.includes(id); return (
@@ -65,13 +70,15 @@ export function CollectionsSidebar({ active={active === "redirects"} onSelect={onSelect} /> - + {shown("site") && ( + + )} + {shown("settings") && ( + + )} - + {showBlog && ( <>
@@ -141,10 +162,12 @@ export function CollectionsSidebar({ function AdvancedGroup({ active, counts, + shown, onSelect, }: { active: CollectionId; counts: CollectionCounts; + shown: (id: CollectionId) => boolean; onSelect: (id: CollectionId) => void; }) { const t = useT(); @@ -182,15 +205,17 @@ function AdvancedGroup({ onSelect={onSelect} indent /> - + {shown("apps") && ( + + )} - + {shown("actions") && ( + + )} ); diff --git a/apps/web/src/components/sandbox/content/content-browser.tsx b/apps/web/src/components/sandbox/content/content-browser.tsx index d83460372d..9d8207f23e 100644 --- a/apps/web/src/components/sandbox/content/content-browser.tsx +++ b/apps/web/src/components/sandbox/content/content-browser.tsx @@ -42,6 +42,7 @@ import { useChatTask } from "@/components/chat/context"; import { useDecofile } from "@/components/sections-editor/use-decofile"; import { useLiveMeta } from "@/components/sections-editor/use-live-meta"; import { hasEditableAppEditorSchema } from "./app-editor-schema"; +import { hasCmsSettingsType } from "./cms-settings"; import { type LiveMeta } from "@/components/sections-editor/resolve-schema"; import { useSaveBlock } from "@/components/sections-editor/use-save-block"; import { useDeleteBlock } from "@/components/sections-editor/use-delete-block"; @@ -58,6 +59,7 @@ import { import type { AppCatalogEntry } from "./app-catalog"; import { ListEmpty } from "./list-empty"; import { useDecoAppsCatalog } from "@/hooks/use-deco-apps-catalog"; +import { useNewBlocksEditorState } from "@/hooks/use-new-blocks-editor"; import { normalizePagePath } from "@/components/sections-editor/page-path-utils"; import { appendPageVariantSections, @@ -70,6 +72,8 @@ import { CollectionsSidebar } from "./collections-sidebar"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { SandboxStateRenderer } from "./sandbox-state-renderer"; +import { SchemalessEditor } from "./schemaless-editor"; +import { isNoSchemaMeta } from "@/components/sections-editor/schemaless"; import { resolveContentSandboxGate } from "./content-sandbox-gate"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { @@ -124,6 +128,11 @@ import { EmptyMessage } from "./empty-message"; import { SectionsRightPane } from "./sections-right-pane"; import { ItemActions } from "./item-actions"; import { ItemRow } from "./item-row"; +import { useContentBackend } from "@/components/sections-editor/use-content-backend"; +import { + isProtocolProject, + servePreviewUrl, +} from "@/components/sections-editor/content-backend"; import { categoryAncestors, type CategoryTreeRow, @@ -138,6 +147,12 @@ const AppEditor = lazy(() => import("./app-editor").then((m) => ({ default: m.AppEditor })), ); +const CmsSettingsEditor = lazy(() => + import("./cms-settings-editor").then((m) => ({ + default: m.CmsSettingsEditor, + })), +); + const PostEditor = lazy(() => import("./blog/post-editor").then((m) => ({ default: m.PostEditor })), ); @@ -188,6 +203,7 @@ export type CollectionId = | "apps" | "site" | "seo" + | "settings" | "calendar" | "post-schedule" | "loaders" @@ -261,8 +277,11 @@ export function ContentBrowser({ deepLinkPage }: ContentBrowserProps) { const lifecycle = useSandboxLifecycle(); const { runtime, previewServerUrl } = useSessionRuntime(virtualMcpId); const fastPreviewActive = runtime === "cms"; + // A content-protocol backend has nothing to boot either. + const contentBackend = useContentBackend(virtualMcpId, branch); + const protocolActive = isProtocolProject(contentBackend); const gate = resolveContentSandboxGate({ - fastPreviewActive, + fastPreviewActive: fastPreviewActive || protocolActive, previewState: lifecycle.previewState, lifecyclePhase: vmEvents.lifecycle.phase, }); @@ -288,9 +307,14 @@ export function ContentBrowser({ deepLinkPage }: ContentBrowserProps) { orgSlug={org.slug} virtualMcpId={virtualMcpId} branch={branch} - previewUrl={lifecycle.previewUrl} + // The protocol never runs site code: nothing executes against a pod. + previewUrl={protocolActive ? null : lifecycle.previewUrl} sitePreviewUrl={ - fastPreviewActive ? previewServerUrl : lifecycle.previewUrl + contentBackend.kind === "protocol" && contentBackend.source === "local" + ? servePreviewUrl(contentBackend) + : fastPreviewActive + ? previewServerUrl + : lifecycle.previewUrl } deepLinkPage={deepLinkPage} devServerReady={gate.devServerReady} @@ -350,6 +374,13 @@ function ContentBrowserReady({ const t = useT(); const threadId = useOptionalChatTask()?.taskId ?? null; const fetchParams = { orgSlug, virtualMcpId, branch, threadId, previewUrl }; + // The protocol never runs site code: no app install, no Run. + const runsSiteCode = !isProtocolProject( + useContentBackend(virtualMcpId, branch), + ); + // Which editor this site gets (v8: the new one): waited on with the data, + // so neither editor shows and then swaps to the other. + const editorUndecided = useNewBlocksEditorState() === undefined; const { data: decofile, isLoading: decofileLoading } = useDecofile( fetchParams, { fetchEnabled: devServerReady }, @@ -375,38 +406,6 @@ function ContentBrowserReady({ ); // Page that should open with the inline SEO form in SectionsEditor. const [openPageSeoKey, setOpenPageSeoKey] = useState(null); - // Storefront "." deep-link: open the visited page once the decofile loads. One-shot, so a later manual selection is never clobbered. - const hasDeepLink = !!( - deepLinkPage?.pageId || - deepLinkPage?.path || - deepLinkPage?.pathTemplate - ); - const [seededDeepLink, setSeededDeepLink] = useState(false); - if (hasDeepLink && !seededDeepLink && selection === null && decofile) { - setSeededDeepLink(true); - const match = resolveDeepLinkPage( - extractPages(decofile), - deepLinkPage ?? {}, - ); - if (match) { - setActiveCollection("pages"); - setSelection({ collection: "pages", key: match.key, path: match.path }); - } - } - const [searchQuery, setSearchQuery] = useState(""); - // Posts workspace view + grouping — lifted so they survive opening a post. - const [postsView, setPostsView] = useState("list"); - const selectItem = (next: Selection) => { - setSelection(next); - setOpenPageSeoKey(null); - }; - // Reset search when switching collections (derived-state sync pattern). - const [prevCollection, setPrevCollection] = useState(activeCollection); - if (prevCollection !== activeCollection) { - setPrevCollection(activeCollection); - setSearchQuery(""); - } - const { data: meta, isLoading: metaLoading, @@ -451,6 +450,44 @@ function ContentBrowserReady({ return false; }, }); + // Storefront "." deep-link: open the visited page once the decofile loads. One-shot, so a later manual selection is never clobbered. + const hasDeepLink = !!( + deepLinkPage?.pageId || + deepLinkPage?.path || + deepLinkPage?.pathTemplate + ); + const [seededDeepLink, setSeededDeepLink] = useState(false); + // Waits for the schema too: it says which block types are pages. + if ( + hasDeepLink && + !seededDeepLink && + selection === null && + decofile && + !metaLoading + ) { + setSeededDeepLink(true); + const match = resolveDeepLinkPage( + extractPages(decofile, meta), + deepLinkPage ?? {}, + ); + if (match) { + setActiveCollection("pages"); + setSelection({ collection: "pages", key: match.key, path: match.path }); + } + } + const [searchQuery, setSearchQuery] = useState(""); + // Posts workspace view + grouping — lifted so they survive opening a post. + const [postsView, setPostsView] = useState("list"); + const selectItem = (next: Selection) => { + setSelection(next); + setOpenPageSeoKey(null); + }; + // Reset search when switching collections (derived-state sync pattern). + const [prevCollection, setPrevCollection] = useState(activeCollection); + if (prevCollection !== activeCollection) { + setPrevCollection(activeCollection); + setSearchQuery(""); + } const isAppSchemaLoading = ( resolveType: string | undefined, @@ -461,7 +498,7 @@ function ContentBrowserReady({ const { catalog: appCatalog, isLoading: appCatalogLoading } = useDecoAppsCatalog(meta ?? undefined, decofile ?? undefined, { - enabled: activeCollection === "apps", + enabled: runsSiteCode && activeCollection === "apps", }); const saveBlock = useSaveBlock(fetchParams); @@ -498,7 +535,12 @@ function ContentBrowserReady({ // terminal phase — or the dev server is up but the fetch failed — fall // through to the error below instead of spinning forever. const dataMissing = !decofile || !meta; - if (decofileLoading || metaLoading || (dataMissing && sandboxWarming)) { + if ( + decofileLoading || + metaLoading || + editorUndecided || + (dataMissing && sandboxWarming) + ) { return (
@@ -510,7 +552,19 @@ function ContentBrowserReady({ return ; } - const pages = extractPages(decofile).sort((a, b) => + // No schema yet: every block, as plain fields, until it appears. + if (isNoSchemaMeta(meta)) { + return ( + + ); + } + + const pages = extractPages(decofile, meta).sort((a, b) => a.name.localeCompare(b.name), ); const redirects = extractRedirects(decofile).sort((a, b) => @@ -529,8 +583,21 @@ function ContentBrowserReady({ ? allBlogEntries[activeCollection] : []; + // Listed (and their saved blocks edited) on every backend; only Run needs + // site code. const loadersCount = countAvailableRunnables(meta, "loaders"); const actionsCount = countAvailableRunnables(meta, "actions"); + // What a content-protocol site has no use for: app installs (no site code + // runs), v7's site app block, and an empty Actions folder. + const hiddenCollections: CollectionId[] = runsSiteCode + ? ["settings"] + : [ + "apps", + ...(siteApp ? [] : (["site"] as const)), + ...(actionsCount === 0 ? (["actions"] as const) : []), + // The CMS settings block, when the site's framework has the type. + ...(hasCmsSettingsType(meta) ? [] : (["settings"] as const)), + ]; // Loader/action-only sites are still editable — don't gate them out. if ( @@ -551,7 +618,7 @@ function ContentBrowserReady({ const counts: CollectionCounts = { pages: pages.length, sections: globalSections.length, - apps: appCatalog.length, + apps: runsSiteCode ? appCatalog.length : 0, loaders: loadersCount, actions: actionsCount, redirects: redirects.length, @@ -959,6 +1026,7 @@ function ContentBrowserReady({
+ {payload.flat && ( +
+ + +

+ {t("sandbox.redirectEditor.statusDescription")} +

+
+ )} +