From 2ea0d82bf966e2c7714aa764d7994d0711925494 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:02:16 -0300 Subject: [PATCH 1/3] feat(web): content-protocol client API; editor hooks use it Adds the content-protocol client API and moves the editor hooks (decofile, save, delete, move, live meta) onto it for protocol projects. v7 paths are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sections-editor/content-protocol-api.ts | 330 ++++++++++++++++++ .../content-protocol-schemaless.test.ts | 141 ++++++++ .../sections-editor/serve-save-error.ts | 61 ++++ .../sections-editor/use-decofile.ts | 57 ++- .../sections-editor/use-delete-block.ts | 23 +- .../sections-editor/use-live-meta.ts | 48 ++- .../sections-editor/use-move-blocks.ts | 21 +- .../sections-editor/use-save-block.ts | 66 +++- knip.jsonc | 6 +- 9 files changed, 710 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/sections-editor/content-protocol-api.ts create mode 100644 apps/web/src/components/sections-editor/content-protocol-schemaless.test.ts create mode 100644 apps/web/src/components/sections-editor/serve-save-error.ts diff --git a/apps/web/src/components/sections-editor/content-protocol-api.ts b/apps/web/src/components/sections-editor/content-protocol-api.ts new file mode 100644 index 0000000000..895896654c --- /dev/null +++ b/apps/web/src/components/sections-editor/content-protocol-api.ts @@ -0,0 +1,330 @@ +/** + * The editor's reads and writes over the content protocol. The data hooks + * (`useDecofile`, `useLiveMeta`, the write hooks) call these when the project + * has a protocol backend, and keep their legacy paths otherwise. + * + * Polling follows the protocol: one batched request reads the blocks and the + * schema, each conditional on the revision (or schema version) last seen, and + * gets a short "not modified" when nothing changed. After a write the client + * adopts the returned revision, so its next poll is "not modified" unless + * someone else wrote. + */ + +import { type QueryClient, useQuery } from "@tanstack/react-query"; +import { ServeLostError } from "./serve-save-error"; +import { + ContentProtocolError, + type BlocksListResult, + type SchemaGetResult, +} from "@decocms/blocks/protocol"; +import { KEYS } from "@/lib/query-keys"; +import { sandboxGitStatusQueryKey } from "../thread/repository/sandbox-git-api"; +import { + blockKeysOfWrite, + mergePolledBlocks, + type ProtocolBackend, +} from "./content-backend"; +import { + type DecofilePatchBody, + type DecofileScopeParams, + decofileWriteMutationKey, +} from "./decofile-api"; +import type { LiveMeta } from "./resolve-schema"; +import { isSchemaAbsent, noSchemaMeta } from "./schemaless"; + +/** + * The read error while a protocol backend can't be used; the 502 keeps the + * hooks' retry rules and the blocks tab's "unreachable" state. + */ +export function protocolUnavailableError(what: string): Error { + const error = new Error(`${what} unavailable (content server unreachable)`); + (error as { status?: number }).status = 502; + return error; +} + +/** What a project's last read or write saw. */ +export interface ContentRevisions { + /** The blocks revision (the branch head on GitHub). */ + revision?: string; + schemaVersion?: string; +} + +interface ProtocolParams extends DecofileScopeParams { + threadId?: string | null; +} + +/** The cache entry of a protocol project's schema (a `useLiveMeta` key). */ +export function protocolMetaQueryKey( + params: DecofileScopeParams, + backend: ProtocolBackend, +) { + return KEYS.liveMeta( + params.orgSlug, + params.virtualMcpId, + params.branch, + `protocol${backend.cacheKeySuffix}`, + "", + ); +} + +/** The block names the project's in-flight writes touch. */ +function savingBlockKeys( + queryClient: QueryClient, + params: DecofileScopeParams, +): Set { + const pending = queryClient.getMutationCache().findAll({ + mutationKey: decofileWriteMutationKey( + params.orgSlug, + params.virtualMcpId, + params.branch, + ), + status: "pending", + }); + return new Set(pending.flatMap((m) => blockKeysOfWrite(m.state.variables))); +} + +/** + * Runs a request against the backend. When a `deco serve` can't be reached + * (it stopped or is restarting), its probe is reset, so the editor waits for + * it and reconnects on its own. + */ +async function guarded( + queryClient: QueryClient, + backend: ProtocolBackend, + params: DecofileScopeParams, + request: () => Promise, +): Promise { + try { + return await request(); + } catch (error) { + const lostServer = !(error instanceof ContentProtocolError); + if (backend.source === "local" && lostServer) { + void queryClient.resetQueries({ + queryKey: KEYS.contentBackend( + params.orgSlug, + params.virtualMcpId, + "local", + ), + }); + throw new ServeLostError(error); + } + throw error; + } +} + +export interface ProtocolContent { + blocks: Record; + /** The schema (`noSchemaMeta()` while there is none), or the error reading it. */ + meta: LiveMeta | ContentProtocolError; +} + +const inFlight = new Map>(); + +/** + * One poll: `blocks.list` and `schema.get` in one request. Updates both the + * decofile and the schema cache entries and resolves with both, so the two + * hooks share one request (concurrent calls for one project join it). + */ +export function pollProtocolContent( + queryClient: QueryClient, + backend: ProtocolBackend, + params: DecofileScopeParams, + cacheKey: string, +): Promise { + const running = inFlight.get(cacheKey); + if (running) return running; + const poll = readContent(queryClient, backend, params, cacheKey, true); + inFlight.set(cacheKey, poll); + return poll.finally(() => inFlight.delete(cacheKey)); +} + +async function readContent( + queryClient: QueryClient, + backend: ProtocolBackend, + params: DecofileScopeParams, + cacheKey: string, + conditional: boolean, +): Promise { + const blocksKey = KEYS.decofile(cacheKey); + const metaKey = protocolMetaQueryKey(params, backend); + const revisionsKey = KEYS.contentRevision(cacheKey); + const currentBlocks = + queryClient.getQueryData>(blocksKey); + const currentMeta = queryClient.getQueryData(metaKey); + const seen = + queryClient.getQueryData(revisionsKey) ?? undefined; + const blocksIfNoneMatch = + conditional && currentBlocks ? seen?.revision : undefined; + const schemaIfNoneMatch = + conditional && currentMeta ? seen?.schemaVersion : undefined; + + const [listed, schema] = await guarded(queryClient, backend, params, () => + backend.client.batch([ + { + method: "blocks.list", + params: blocksIfNoneMatch ? { ifNoneMatch: blocksIfNoneMatch } : {}, + }, + { + method: "schema.get", + params: schemaIfNoneMatch ? { ifNoneMatch: schemaIfNoneMatch } : {}, + }, + ]), + ); + if (!listed?.ok) throw listed?.error ?? new Error("no blocks.list"); + if (!schema) throw new Error("no schema.get"); + const list = listed.result as BlocksListResult; + // No schema yet: an empty, marked schema, so the blocks still open (as + // plain fields) and the next poll asks again unconditionally. + const absent = isSchemaAbsent(schema); + const read = schema.ok && !absent ? (schema.result as SchemaGetResult) : null; + // A version outlived the entry it described: read both whole. + if ( + (list.notModified && !currentBlocks) || + (read?.notModified && !currentMeta) + ) { + return readContent(queryClient, backend, params, cacheKey, false); + } + + const blocks = list.notModified + ? currentBlocks! + : mergePolledBlocks( + list.blocks, + currentBlocks, + savingBlockKeys(queryClient, params), + ); + const meta: LiveMeta | ContentProtocolError = absent + ? noSchemaMeta() + : !read + ? (schema as { error: ContentProtocolError }).error + : read.notModified + ? currentMeta! + : (read.schema as LiveMeta); + queryClient.setQueryData(revisionsKey, { + revision: list.notModified ? seen?.revision : list.revision, + schemaVersion: !read + ? undefined + : read.notModified + ? seen?.schemaVersion + : (read.version ?? undefined), + }); + queryClient.setQueryData(blocksKey, blocks); + if (!(meta instanceof ContentProtocolError)) { + queryClient.setQueryData(metaKey, meta); + } + return { blocks, meta }; +} + +/** + * One `blocks.apply`; adopts the returned revision. On GitHub the save + * rewrote the project's CDN draft, so the publish status and the draft + * pointer (`useProtocolDraft`, whose version is the draft's ETag) are + * refreshed. In a sandbox the save is a working-tree change, so the header's + * status is refreshed. + * `ifMatch` guards entries by version (`null`: only if it doesn't exist yet); + * a failed guard rejects the whole patch with a Conflict. + */ +export async function applyProtocolPatch( + queryClient: QueryClient, + backend: ProtocolBackend, + params: ProtocolParams, + cacheKey: string, + patch: DecofilePatchBody, + ifMatch?: Record, +): Promise<{ revision: string }> { + const result = await guarded(queryClient, backend, params, () => + backend.client.blocksApply({ + set: patch.set as Record> | undefined, + delete: patch.delete, + ...(ifMatch ? { ifMatch } : {}), + }), + ); + queryClient.setQueryData( + KEYS.contentRevision(cacheKey), + (seen) => ({ ...seen, revision: result.revision }), + ); + if (backend.source === "github") { + void queryClient.invalidateQueries({ + queryKey: KEYS.protocolDraftRead( + params.orgSlug, + params.virtualMcpId, + params.branch, + ), + }); + await queryClient.invalidateQueries({ + queryKey: sandboxGitStatusQueryKey({ + orgSlug: params.orgSlug, + virtualMcpId: params.virtualMcpId, + branch: params.branch, + threadId: params.threadId ?? null, + }), + }); + } + if (backend.source === "sandbox") { + // The save changed the working tree: the header's changes count moves. + void queryClient.invalidateQueries({ + queryKey: sandboxGitStatusQueryKey({ + orgSlug: params.orgSlug, + virtualMcpId: params.virtualMcpId, + branch: params.branch, + threadId: params.threadId ?? null, + }), + }); + } + return { revision: result.revision }; +} + +/** What the session read answers for a hosted v8 project. */ +interface HostedDraftPointer { + /** `delivery.decocms.com/sites//drafts/.json`; null without a draft. */ + draft: string | null; + /** The draft object's ETag, so each save makes a new pointer. */ + version: string | null; +} + +/** + * The `?__draft=` pointer of a project on the GitHub backend: its draft on + * the delivery CDN, `@`, which the site's SDK revalidates on + * every render. Read from the session's decofile GET, on load and again + * after each save (see `applyProtocolPatch`). `params` is `null` for any + * other backend; the pointer is null while there is no draft. + */ +export function useProtocolDraft( + params: DecofileScopeParams | null, +): string | null { + const { data } = useQuery({ + queryKey: KEYS.protocolDraftRead( + params?.orgSlug ?? "", + params?.virtualMcpId ?? "", + params?.branch ?? "", + ), + queryFn: async (): Promise => { + const res = await fetch( + `/api/${params!.orgSlug}/decofile/${encodeURIComponent(params!.virtualMcpId)}/${encodeURIComponent(params!.branch)}`, + { cache: "no-store" }, + ); + if (!res.ok) throw new Error(`draft pointer read failed: ${res.status}`); + return (await res.json()) as HostedDraftPointer; + }, + enabled: !!params, + staleTime: Number.POSITIVE_INFINITY, + }); + if (!params || !data?.draft || !data.version) return null; + return `${data.draft}@${data.version}`; +} + +/** + * The content revision the last read or write of `cacheKey` saw, or + * `undefined` before the first one. A save moves it, and so does a read that + * finds the content changed elsewhere. + */ +export function useContentRevision(cacheKey: string): string | undefined { + const { data: seen } = useQuery({ + queryKey: KEYS.contentRevision(cacheKey), + enabled: false, + queryFn: async () => { + throw new Error("content revisions are set by reads and writes"); + }, + staleTime: Number.POSITIVE_INFINITY, + }); + return seen?.revision; +} diff --git a/apps/web/src/components/sections-editor/content-protocol-schemaless.test.ts b/apps/web/src/components/sections-editor/content-protocol-schemaless.test.ts new file mode 100644 index 0000000000..6f3a032c78 --- /dev/null +++ b/apps/web/src/components/sections-editor/content-protocol-schemaless.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; +import { + type BatchCall, + type BatchOutcome, + type ContentClient, + ContentProtocolError, + type DescribeResult, + ErrorCode, +} from "@decocms/blocks/protocol"; +import type { ProtocolBackend } from "./content-backend"; +import { + pollProtocolContent, + protocolMetaQueryKey, +} from "./content-protocol-api"; +import type { LiveMeta } from "./resolve-schema"; +import { isNoSchemaMeta } from "./schemaless"; + +const params = { + orgSlug: "", + virtualMcpId: "deco-serve", + branch: "working-tree", +}; +const blocks = { + hero: { __resolveType: "site/sections/Hero.tsx", title: "Hi" }, +}; +const schema = { + manifest: { blocks: {} }, + schema: { definitions: {}, root: {} }, +}; + +/** A `deco serve` whose schema the test swaps; it records each batch. */ +function fakeServer(initial: "none" | "not-found" | "present") { + const state = { + schema: initial as "none" | "not-found" | "present", + calls: [] as BatchCall[][], + }; + const schemaOutcome = (call: BatchCall): BatchOutcome => { + if (state.schema === "not-found") { + return { + ok: false, + error: new ContentProtocolError(ErrorCode.NotFound, "no schema"), + }; + } + if (state.schema === "none") { + return { + ok: true, + result: { + notModified: false, + version: null, + resolvedRef: null, + schema: null, + }, + }; + } + const ifNoneMatch = (call.params as { ifNoneMatch?: string } | undefined) + ?.ifNoneMatch; + return { + ok: true, + result: + ifNoneMatch === "s1" + ? { notModified: true, version: "s1" } + : { notModified: false, version: "s1", resolvedRef: null, schema }, + }; + }; + const client = { + batch: async (calls: BatchCall[]) => { + state.calls.push(calls); + return calls.map( + (call): BatchOutcome => + call.method === "schema.get" + ? schemaOutcome(call) + : { + ok: true, + result: { + notModified: false, + revision: "r1", + resolvedRef: null, + blocks, + versions: { hero: "v1" }, + diagnostics: [], + }, + }, + ); + }, + } as unknown as ContentClient; + const backend: ProtocolBackend = { + kind: "protocol", + source: "local", + client, + describe: { pollIntervalMs: 2000 } as DescribeResult, + cacheKeySuffix: ":serve:test", + hasSchema: initial === "present", + }; + return { state, backend }; +} + +describe("pollProtocolContent without a schema", () => { + for (const initial of ["none", "not-found"] as const) { + it(`lists the blocks with an empty, marked schema (${initial}), then picks the schema up`, async () => { + const queryClient = new QueryClient(); + const { state, backend } = fakeServer(initial); + + const first = await pollProtocolContent( + queryClient, + backend, + params, + "k", + ); + expect(first.blocks).toEqual(blocks); + expect(isNoSchemaMeta(first.meta as LiveMeta)).toBe(true); + const cached = queryClient.getQueryData( + protocolMetaQueryKey(params, backend), + ); + expect(isNoSchemaMeta(cached)).toBe(true); + + // The next poll asks for the schema unconditionally: there's no version yet. + await pollProtocolContent(queryClient, backend, params, "k"); + const second = state.calls[1]!.find((c) => c.method === "schema.get"); + expect(second?.params).toEqual({}); + + // `deco schema` ran: the next poll swaps the real schema in. + state.schema = "present"; + const third = await pollProtocolContent( + queryClient, + backend, + params, + "k", + ); + expect(third.meta).toEqual(schema); + expect(isNoSchemaMeta(third.meta as LiveMeta)).toBe(false); + expect( + isNoSchemaMeta( + queryClient.getQueryData( + protocolMetaQueryKey(params, backend), + ), + ), + ).toBe(false); + }); + } +}); diff --git a/apps/web/src/components/sections-editor/serve-save-error.ts b/apps/web/src/components/sections-editor/serve-save-error.ts new file mode 100644 index 0000000000..a1304c1688 --- /dev/null +++ b/apps/web/src/components/sections-editor/serve-save-error.ts @@ -0,0 +1,61 @@ +/** + * Why a save or upload to the content protocol failed, in words: a conflict + * with a change made on disk, a read-only `deco serve`, content it refused, + * or a server that stopped answering. Anything else keeps the generic + * "Save failed" with the error's own text (the GitHub backend and the legacy + * paths throw their own messages). + */ + +import { + type BlockViolation, + ContentProtocolError, + ErrorCode, +} from "@decocms/blocks/protocol"; +import type { TFunction } from "@/i18n/use-t.ts"; + +/** A local `deco serve` request that got no answer (see `guarded`). */ +export class ServeLostError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + this.name = "ServeLostError"; + } +} + +/** Whether a failed save was refused for an outdated `ifMatch`. */ +export function isSaveConflict(error: unknown): boolean { + return ( + error instanceof ContentProtocolError && error.code === ErrorCode.Conflict + ); +} + +function violationsDetail(error: ContentProtocolError): string { + const violations = (error.data as { violations?: BlockViolation[] } | null) + ?.violations; + if (!Array.isArray(violations) || violations.length === 0) { + return error.message; + } + return violations + .slice(0, 3) + .map((violation) => violation.message) + .join("; "); +} + +export function saveErrorMessage(t: TFunction, error: Error): string { + if (error instanceof ServeLostError) return t("decoServe.save.serverGone"); + if (error instanceof ContentProtocolError) { + switch (error.code) { + case ErrorCode.Conflict: + return t("decoServe.save.conflict"); + case ErrorCode.ReadOnly: + return t("decoServe.save.readOnly"); + case ErrorCode.InvalidBlock: + case ErrorCode.InvalidParams: + return t("decoServe.save.invalid", { detail: violationsDetail(error) }); + case ErrorCode.LimitExceeded: + return t("decoServe.save.tooLarge"); + } + } + return t("sectionsEditor.sectionsEditor.saveFailed", { + error: error.message, + }); +} diff --git a/apps/web/src/components/sections-editor/use-decofile.ts b/apps/web/src/components/sections-editor/use-decofile.ts index cba854011d..77692957fb 100644 --- a/apps/web/src/components/sections-editor/use-decofile.ts +++ b/apps/web/src/components/sections-editor/use-decofile.ts @@ -10,6 +10,11 @@ import { buildDecofileFetchUrl } from "./preview-fetch-url"; import { readCommittedJson } from "./read-committed-file"; import { decofileErrorStatus } from "./decofile-read-status"; import { usePackagePath } from "./use-package-path"; +import { useContentBackend } from "./use-content-backend"; +import { + pollProtocolContent, + protocolUnavailableError, +} from "./content-protocol-api"; interface UseDecofileParams { orgSlug: string; @@ -27,18 +32,40 @@ interface UseDecofileParams { * looks. Same string ⇒ shared cache; a Local edit never bleeds into the real * branch's decofile and vice-versa. */ -export function decofileCacheKey(input: { +function decofileCacheKey(input: { orgSlug: string; virtualMcpId: string; branch: string; localPreviewUrl?: string | null; + /** A content-protocol backend's suffix (a `deco serve` endpoint), if any. */ + protocolSuffix?: string | null; }): string { const base = `${input.orgSlug}/${input.virtualMcpId}/${input.branch}`; + if (input.protocolSuffix) return `${base}${input.protocolSuffix}`; return input.localPreviewUrl ? `${base}:local:${input.localPreviewUrl}` : base; } +/** + * The decofile cache key for a project, given its content backend. Pass + * `tunnel: false` for writers that never target the Local tunnel's entry. + */ +export function useDecofileCacheKey( + params: { orgSlug: string; virtualMcpId: string; branch: string } | null, + options?: { tunnel?: boolean }, +): string { + const { url: localPreviewUrl } = useLocalPreviewUrl(params?.virtualMcpId); + const backend = useContentBackend(params?.virtualMcpId, params?.branch); + if (!params) return ""; + return backend.kind === "protocol" + ? decofileCacheKey({ ...params, protocolSuffix: backend.cacheKeySuffix }) + : decofileCacheKey({ + ...params, + localPreviewUrl: options?.tunnel === false ? null : localPreviewUrl, + }); +} + export function useDecofile( params: UseDecofileParams | null, options?: { fetchEnabled?: boolean }, @@ -51,7 +78,10 @@ export function useDecofile( */ const { url: localPreviewUrl } = useLocalPreviewUrl(params?.virtualMcpId); const localOverride = !!localPreviewUrl; - const key = params ? decofileCacheKey({ ...params, localPreviewUrl }) : ""; + // Next-major Blocks sites: the content protocol, polled (see content-backend). + const backend = useContentBackend(params?.virtualMcpId, params?.branch); + const protocol = backend.kind === "protocol" ? backend : null; + const key = useDecofileCacheKey(params); // `fetchEnabled` means the dev server is up, so the live `/.decofile` route is // worth hitting. When it's down we read `.deco/blocks.gen.json` straight from // the working tree — and if that artifact is absent (it's commonly gitignored) @@ -82,6 +112,18 @@ export function useDecofile( return useQuery({ queryKey: KEYS.decofile(key), queryFn: async () => { + if (protocol) { + const content = await pollProtocolContent( + queryClient, + protocol, + params!, + key, + ); + return content.blocks; + } + if (backend.kind === "unavailable") { + throw protocolUnavailableError("decofile"); + } if (localOverride) { const res = await fetch( new URL("/.decofile", localPreviewUrl).toString(), @@ -142,8 +184,11 @@ export function useDecofile( }); throw err; }, - enabled: !!params, - staleTime: 30_000, + enabled: !!params && backend.kind !== "pending", + staleTime: protocol ? 0 : 30_000, + refetchInterval: protocol?.describe.pollIntervalMs, + refetchIntervalInBackground: false, + refetchOnWindowFocus: protocol ? "always" : undefined, // 502 = preview unreachable / nothing available yet. Retrying just hammers // a known-down endpoint; sandbox-events-context re-invalidates this query // on first daemon contact (and again when the dev server reports @@ -158,7 +203,9 @@ export function useDecofile( // 404 = no decofile route on this repo (not a deco site) — as terminal as // 502 for retry purposes. retry: (failureCount, error) => { - if (fastPreviewActive || localOverride) return failureCount < 3; + if (protocol || fastPreviewActive || localOverride) { + return failureCount < 3; + } const status = (error as { status?: number }).status; return status !== 502 && status !== 404 && failureCount < 2; }, diff --git a/apps/web/src/components/sections-editor/use-delete-block.ts b/apps/web/src/components/sections-editor/use-delete-block.ts index 6451b2292a..b25a1f89ad 100644 --- a/apps/web/src/components/sections-editor/use-delete-block.ts +++ b/apps/web/src/components/sections-editor/use-delete-block.ts @@ -1,7 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url"; -import { decofileCacheKey } from "./use-decofile"; import { usePackagePath } from "./use-package-path"; import { KEYS } from "@/lib/query-keys"; import { decoBlockFilePath } from "./deco-block-key"; @@ -15,6 +14,9 @@ import { } from "./decofile-api"; import { sandboxGitStatusQueryKey } from "../thread/repository/sandbox-git-api"; import { useOptionalChatTask } from "@/components/chat/chat-context"; +import { useContentBackend } from "./use-content-backend"; +import { applyProtocolPatch } from "./content-protocol-api"; +import { useDecofileCacheKey } from "./use-decofile"; import { buildSandboxUrl } from "@/sdk/sandbox-url"; interface UseDeleteBlockParams { @@ -47,17 +49,24 @@ export function useDeleteBlock({ * reading it without the tunnel patched a decofile nobody is rendering. */ const { url: localPreviewUrl } = useLocalPreviewUrl(virtualMcpId); - const cacheKey = decofileCacheKey({ - orgSlug, - virtualMcpId, - branch, - localPreviewUrl, - }); + const backend = useContentBackend(virtualMcpId, branch); + const protocol = backend.kind === "protocol" ? backend : null; + const cacheKey = useDecofileCacheKey({ orgSlug, virtualMcpId, branch }); return useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), scope: decofileWriteScope(orgSlug, virtualMcpId, branch), mutationFn: async ({ blockKey }: { blockKey: string }) => { + if (protocol) { + await applyProtocolPatch( + queryClient, + protocol, + { orgSlug, virtualMcpId, branch, threadId }, + cacheKey, + { delete: [blockKey] }, + ); + return { ok: true as const, existed: true }; + } // Local: no persistence — the optimistic cache removal is the delete. if (localPreviewUrl) return { ok: true as const, existed: true }; if (fastPreviewActive) { diff --git a/apps/web/src/components/sections-editor/use-live-meta.ts b/apps/web/src/components/sections-editor/use-live-meta.ts index 27ca22f723..3d269fa4af 100644 --- a/apps/web/src/components/sections-editor/use-live-meta.ts +++ b/apps/web/src/components/sections-editor/use-live-meta.ts @@ -1,4 +1,4 @@ -import { type Query, useQuery } from "@tanstack/react-query"; +import { type Query, useQuery, useQueryClient } from "@tanstack/react-query"; import { exponentialBackoffWithJitter } from "@decocms/shared/std"; import { KEYS } from "@/lib/query-keys"; import { decoRepoPath } from "./deco-repo-path"; @@ -10,6 +10,13 @@ import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url"; import { usePackagePath } from "./use-package-path"; import type { LiveMeta } from "./resolve-schema"; +import { useContentBackend } from "./use-content-backend"; +import { useDecofileCacheKey } from "./use-decofile"; +import { + pollProtocolContent, + protocolMetaQueryKey, + protocolUnavailableError, +} from "./content-protocol-api"; interface UseLiveMetaParams { orgSlug: string; @@ -114,11 +121,34 @@ export function useLiveMeta( params?.virtualMcpId, ); const fastPreviewActive = !localOverride && runtime === "cms"; + // Next-major Blocks sites read the committed schema over the content + // protocol: never from the running site. + const backend = useContentBackend(params?.virtualMcpId, params?.branch); + const protocol = backend.kind === "protocol" ? backend : null; + const queryClient = useQueryClient(); + const decofileKey = useDecofileCacheKey(params ?? null); + const queryKey = params + ? protocol + ? protocolMetaQueryKey(params, protocol) + : liveMetaQueryKey({ ...params, previewUrl, productionUrl }) + : KEYS.liveMeta(""); return useQuery({ - queryKey: params - ? liveMetaQueryKey({ ...params, previewUrl, productionUrl }) - : KEYS.liveMeta(""), + queryKey, queryFn: async () => { + if (protocol) { + // The same batched poll as the decofile's: one request for both. + const { meta } = await pollProtocolContent( + queryClient, + protocol, + params!, + decofileKey, + ); + if (meta instanceof Error) throw meta; + return meta; + } + if (backend.kind === "unavailable") { + throw protocolUnavailableError("live meta"); + } const fetchMeta = async (baseUrl: string): Promise => { const url = new URL("/live/_meta", baseUrl).href; const res = await fetch(url, { cache: "no-store" }).catch(() => null); @@ -172,10 +202,12 @@ export function useLiveMeta( (err as { status?: number }).status = 502; throw err; }, - enabled: !!params, - refetchInterval: options?.refetchInterval, + enabled: !!params && backend.kind !== "pending", + // On the protocol, the decofile's poll refreshes the schema too. + refetchInterval: protocol ? false : options?.refetchInterval, refetchIntervalInBackground: false, - staleTime: 300_000, + refetchOnWindowFocus: protocol ? false : undefined, + staleTime: protocol ? protocol.describe.pollIntervalMs : 300_000, // 502 = nothing reachable yet. The sandbox lifecycle re-invalidates this // query when the dev server comes up (see sandbox-events-context), so // retrying just hammers a known-down endpoint. Sandbox-less Fast Preview @@ -184,7 +216,7 @@ export function useLiveMeta( // retries ARE the recovery there. // Local mode, like sandbox-less Fast Preview, has no lifecycle event to re-invalidate a 502, so bounded retries are its recovery. retry: (failureCount, error) => - fastPreviewActive || localOverride + protocol || fastPreviewActive || localOverride ? failureCount < 3 : (error as { status?: number }).status !== 502 && failureCount < 3, retryDelay: (attempt) => diff --git a/apps/web/src/components/sections-editor/use-move-blocks.ts b/apps/web/src/components/sections-editor/use-move-blocks.ts index 56aead1c8e..36d8bc23de 100644 --- a/apps/web/src/components/sections-editor/use-move-blocks.ts +++ b/apps/web/src/components/sections-editor/use-move-blocks.ts @@ -1,7 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url"; -import { decofileCacheKey } from "./use-decofile"; import { usePackagePath } from "./use-package-path"; import { KEYS } from "@/lib/query-keys"; import { sanitizeSecretsForPersistence } from "@decocms/shared/decofile"; @@ -16,6 +15,9 @@ import { } from "./decofile-api"; import { sandboxGitStatusQueryKey } from "../thread/repository/sandbox-git-api"; import { useOptionalChatTask } from "@/components/chat/chat-context"; +import { useContentBackend } from "./use-content-backend"; +import { applyProtocolPatch } from "./content-protocol-api"; +import { useDecofileCacheKey } from "./use-decofile"; import { buildSandboxUrl } from "@/sdk/sandbox-url"; interface UseMoveBlocksParams { @@ -63,15 +65,26 @@ export function useMoveBlocks({ * nothing reads AND tried a real write against the branch. */ const { url: localPreviewUrl } = useLocalPreviewUrl(virtualMcpId); - const queryKey = KEYS.decofile( - decofileCacheKey({ orgSlug, virtualMcpId, branch, localPreviewUrl }), - ); + const backend = useContentBackend(virtualMcpId, branch); + const protocol = backend.kind === "protocol" ? backend : null; + const cacheKey = useDecofileCacheKey({ orgSlug, virtualMcpId, branch }); + const queryKey = KEYS.decofile(cacheKey); const mutation = useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), scope: decofileWriteScope(orgSlug, virtualMcpId, branch), mutationFn: async ({ writes: rawWrites, deletes }: BlockMove) => { const writes = sanitizeSecretsForPersistence(rawWrites); + if (protocol) { + // One `blocks.apply`: the write and the delete land together. + return applyProtocolPatch( + queryClient, + protocol, + { orgSlug, virtualMcpId, branch, threadId }, + cacheKey, + { set: writes, delete: deletes.filter((key) => !(key in writes)) }, + ); + } // Local: no persistence — the optimistic cache write is the save. if (localPreviewUrl) return { ok: true as const }; if (fastPreviewActive) { diff --git a/apps/web/src/components/sections-editor/use-save-block.ts b/apps/web/src/components/sections-editor/use-save-block.ts index 79d3b45708..278c6dbeff 100644 --- a/apps/web/src/components/sections-editor/use-save-block.ts +++ b/apps/web/src/components/sections-editor/use-save-block.ts @@ -2,7 +2,9 @@ import { useEffect, useRef } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url"; -import { decofileCacheKey } from "./use-decofile"; +import { useDecofileCacheKey } from "./use-decofile"; +import { useContentBackend } from "./use-content-backend"; +import { applyProtocolPatch } from "./content-protocol-api"; import { usePackagePath } from "./use-package-path"; import { toast } from "sonner"; import { sanitizeSecretsForPersistence } from "@decocms/shared/decofile"; @@ -20,6 +22,7 @@ import { useOptionalChatTask } from "@/components/chat/chat-context"; import { buildSandboxUrl } from "@/sdk/sandbox-url"; import { KEYS } from "@/lib/query-keys"; import { useT } from "@/i18n/use-t"; +import { isSaveConflict, saveErrorMessage } from "./serve-save-error"; /** Debounce window for form-driven block autosaves (ms). */ export const AUTOSAVE_DELAY = 700; @@ -30,6 +33,20 @@ interface UseSaveBlockParams { branch: string; } +/** + * A `blocks.apply` version guard for one save, decided when the save runs + * (saves of a branch run one at a time), not when it's scheduled. `ifMatch` + * returns the guard, or undefined for none; `onApplied` runs as soon as the + * guarded write lands, before the next save of the branch starts. Only the + * content-protocol backend sends it; the others have no versions. + */ +export interface SaveGuard { + ifMatch: (blockKey: string) => Record | undefined; + onApplied?: () => void; + /** A save the guard refused (or any failed guarded save): re-read the content. */ + onRejected?: (error: unknown) => void; +} + export function useSaveBlock({ orgSlug, virtualMcpId, @@ -49,12 +66,10 @@ export function useSaveBlock({ * in-place render picks the merged decofile up and repaints the tunnel frame. */ const { url: localPreviewUrl } = useLocalPreviewUrl(virtualMcpId); - const cacheKey = decofileCacheKey({ - orgSlug, - virtualMcpId, - branch, - localPreviewUrl, - }); + // Next-major Blocks sites: one `blocks.apply` per save. + const backend = useContentBackend(virtualMcpId, branch); + const protocol = backend.kind === "protocol" ? backend : null; + const cacheKey = useDecofileCacheKey({ orgSlug, virtualMcpId, branch }); return useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), @@ -63,12 +78,32 @@ export function useSaveBlock({ mutationFn: async ({ blockKey, data, + guard, }: { blockKey: string; data: unknown; + guard?: SaveGuard; }) => { // Fail closed before any write path: a raw secret must never be persisted. data = sanitizeSecretsForPersistence(data); + if (protocol) { + const ifMatch = guard?.ifMatch(blockKey); + try { + const result = await applyProtocolPatch( + queryClient, + protocol, + { orgSlug, virtualMcpId, branch, threadId }, + cacheKey, + { set: { [blockKey]: data } }, + ifMatch, + ); + if (ifMatch) guard?.onApplied?.(); + return result; + } catch (error) { + if (ifMatch) guard?.onRejected?.(error); + throw error; + } + } // Local: no persistence — the optimistic cache write is the save. if (localPreviewUrl) return { blockKey, data }; if (fastPreviewActive) { @@ -127,8 +162,12 @@ export function useSaveBlock({ return { queryKey, blockKey, hadKey, previousValue }; }, // Restore only this mutation's own key so a concurrent sibling save isn't clobbered. - onError: (_error, _variables, context) => { + onError: (error, _variables, context) => { if (!context) return; + // Refused because the content changed on disk: load what is there now. + if (isSaveConflict(error)) { + void queryClient.invalidateQueries({ queryKey: context.queryKey }); + } queryClient.setQueryData( context.queryKey, (current: Record | undefined) => { @@ -175,7 +214,7 @@ type SaveData = */ export function useDebouncedSaveBlock( params: UseSaveBlockParams, - opts?: { onSaved?: () => void }, + opts?: { onSaved?: () => void; guard?: SaveGuard }, ) { const saveBlock = useSaveBlock(params); const t = useT(); @@ -191,15 +230,10 @@ export function useDebouncedSaveBlock( pendingRef.current.delete(blockKey); if (!resolved) return; saveBlock.mutate( - { blockKey, data: resolved }, + { blockKey, data: resolved, guard: opts?.guard }, { onSuccess: () => opts?.onSaved?.(), - onError: (err) => - toast.error( - t("sectionsEditor.sectionsEditor.saveFailed", { - error: err.message, - }), - ), + onError: (err) => toast.error(saveErrorMessage(t, err)), }, ); }; diff --git a/knip.jsonc b/knip.jsonc index 5e67d42b95..0af3ce85c7 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -82,9 +82,9 @@ "apps/web/src/components/sections-editor/deco-serve-connection.ts": [ "exports" ], - "apps/web/src/components/sections-editor/use-content-backend.ts": [ + "apps/web/src/hooks/use-deco-serve-connection.ts": ["exports"], + "apps/web/src/components/sections-editor/content-protocol-api.ts": [ "exports" - ], - "apps/web/src/hooks/use-deco-serve-connection.ts": ["exports"] + ] } } From f59869014431368fe04b7afd0ac1f3c227759cde Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 9 Oct 2026 11:52:19 -0300 Subject: [PATCH 2/3] fix(web): site editor save errors in plain words, one toast A save that fails on the hosted backend, a sandbox or the legacy paths now says what happened and what to do ("Couldn't save your change. Try again.", "someone else changed this at the same time") instead of the deco serve copy (your computer, git) or the raw error. The error's own text moves behind a Details disclosure. A local deco serve keeps its developer copy. Every save failure shares one sonner id, so a burst of failing autosaves shows one toast, not a stack. A test keeps CDN/git/branch/commit/merge jargon out of the new siteEditor.* copy (en and pt-br). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sections-editor/serve-save-error.test.ts | 86 +++++++++++++++++++ .../sections-editor/serve-save-error.ts | 52 +++++++++-- .../sections-editor/site-editor-toast.tsx | 48 +++++++++++ .../sections-editor/use-save-block.ts | 17 ++-- apps/web/src/i18n/en/index.ts | 2 + apps/web/src/i18n/en/site-editor.ts | 16 ++++ apps/web/src/i18n/pt-br/index.ts | 2 + apps/web/src/i18n/pt-br/site-editor.ts | 13 +++ apps/web/src/i18n/site-editor-copy.test.ts | 50 +++++++++++ 9 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/sections-editor/serve-save-error.test.ts create mode 100644 apps/web/src/components/sections-editor/site-editor-toast.tsx create mode 100644 apps/web/src/i18n/en/site-editor.ts create mode 100644 apps/web/src/i18n/pt-br/site-editor.ts create mode 100644 apps/web/src/i18n/site-editor-copy.test.ts diff --git a/apps/web/src/components/sections-editor/serve-save-error.test.ts b/apps/web/src/components/sections-editor/serve-save-error.test.ts new file mode 100644 index 0000000000..3469465d26 --- /dev/null +++ b/apps/web/src/components/sections-editor/serve-save-error.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { ContentProtocolError, ErrorCode } from "@decocms/blocks/protocol"; +import { toast } from "sonner"; +import { en } from "@/i18n/en/index.ts"; +import { interpolate } from "@/i18n/interpolate.ts"; +import type { TFunction } from "@/i18n/use-t.ts"; +import { + ServeLostError, + saveErrorDetail, + saveErrorMessage, +} from "./serve-save-error"; +import { SITE_EDITOR_SAVE_TOAST, toastSaveError } from "./site-editor-toast"; + +const t: TFunction = (key, vars) => interpolate(en[key], vars); + +const conflict = () => + new ContentProtocolError(ErrorCode.Conflict, "a precondition failed"); + +describe("save messages for business users (hosted, sandbox, legacy)", () => { + for (const source of ["github", "sandbox", null] as const) { + test(`${source ?? "legacy"}: plain words, never the server or git`, () => { + const cases: [Error, RegExp][] = [ + [conflict(), /someone else changed this/], + [ + new ContentProtocolError(ErrorCode.ReadOnly, "read-only"), + /can't be edited/, + ], + [ + new ContentProtocolError(ErrorCode.LimitExceeded, "too big"), + /too large/, + ], + [new Error("HTTP 502 from api.github.com"), /^Couldn't save/], + [new ServeLostError(new TypeError("Failed to fetch")), /^Couldn't/], + ]; + for (const [error, message] of cases) { + const text = saveErrorMessage(t, error, source); + expect(text).toMatch(message); + for (const jargon of [/deco serve/i, /\bgit\b/i, /computer/i]) { + expect(text).not.toMatch(jargon); + } + // The error's own text is never the headline: it goes to Details. + expect(text).not.toContain(error.message); + expect(saveErrorDetail(error, source)).toContain(error.message); + } + }); + } + + test("a refused block names what to fix", () => { + const error = new ContentProtocolError(ErrorCode.InvalidBlock, "invalid", { + violations: [ + { name: "Header", rule: "required", message: "title is required" }, + ], + }); + expect(saveErrorMessage(t, error, "github")).toBe( + "Not saved: title is required. Fix it and try again.", + ); + }); +}); + +describe("save messages for a local deco serve (a developer)", () => { + test("keep naming the server and the error's own text", () => { + expect(saveErrorMessage(t, conflict(), "local")).toMatch( + /changed on your computer/, + ); + expect(saveErrorMessage(t, new Error("disk full"), "local")).toBe( + "Save failed: disk full", + ); + expect(saveErrorDetail(new Error("disk full"), "local")).toBeNull(); + }); +}); + +describe("one toast per save failure", () => { + afterEach(() => { + toast.dismiss(); + }); + + test("a burst of failing autosaves shows a single toast", () => { + for (let i = 0; i < 3; i++) { + toastSaveError(t, new Error(`HTTP 50${i}`), "github"); + } + const shown = toast + .getToasts() + .filter((item) => item.id === SITE_EDITOR_SAVE_TOAST); + expect(shown).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/sections-editor/serve-save-error.ts b/apps/web/src/components/sections-editor/serve-save-error.ts index a1304c1688..71ae24a4f9 100644 --- a/apps/web/src/components/sections-editor/serve-save-error.ts +++ b/apps/web/src/components/sections-editor/serve-save-error.ts @@ -1,9 +1,10 @@ /** - * Why a save or upload to the content protocol failed, in words: a conflict - * with a change made on disk, a read-only `deco serve`, content it refused, - * or a server that stopped answering. Anything else keeps the generic - * "Save failed" with the error's own text (the GitHub backend and the legacy - * paths throw their own messages). + * Why a save failed, in words for whoever is editing. A local `deco serve` + * (`source: "local"`) is a developer's own machine: its copy names the + * server, the disk and git, and keeps the error's own text. Everywhere else + * (Studio's hosted backend, a sandbox, the legacy paths) the editor is a + * business user: plain words and a next step, with the error's own text kept + * for a "Details" disclosure ({@link saveErrorDetail}), never the headline. */ import { @@ -12,6 +13,7 @@ import { ErrorCode, } from "@decocms/blocks/protocol"; import type { TFunction } from "@/i18n/use-t.ts"; +import type { ContentSource } from "./content-backend"; /** A local `deco serve` request that got no answer (see `guarded`). */ export class ServeLostError extends Error { @@ -40,7 +42,45 @@ function violationsDetail(error: ContentProtocolError): string { .join("; "); } -export function saveErrorMessage(t: TFunction, error: Error): string { +export function saveErrorMessage( + t: TFunction, + error: Error, + source: ContentSource | null, +): string { + if (source === "local") return localSaveErrorMessage(t, error); + if (error instanceof ContentProtocolError) { + switch (error.code) { + case ErrorCode.Conflict: + return t("siteEditor.save.conflict"); + case ErrorCode.ReadOnly: + return t("siteEditor.save.readOnly"); + case ErrorCode.InvalidBlock: + case ErrorCode.InvalidParams: + return t("siteEditor.save.invalid", { + detail: violationsDetail(error), + }); + case ErrorCode.LimitExceeded: + return t("siteEditor.save.tooLarge"); + } + } + return t("siteEditor.save.failed"); +} + +/** + * The error's own text, for the "Details" disclosure under a business-user + * message; null when the message already says it all (a local `deco serve`, + * which keeps the text in its message, or an error with no text). + */ +export function saveErrorDetail( + error: Error, + source: ContentSource | null, +): string | null { + if (source === "local") return null; + const code = error instanceof ContentProtocolError ? `${error.code}: ` : ""; + return error.message ? `${code}${error.message}` : null; +} + +function localSaveErrorMessage(t: TFunction, error: Error): string { if (error instanceof ServeLostError) return t("decoServe.save.serverGone"); if (error instanceof ContentProtocolError) { switch (error.code) { diff --git a/apps/web/src/components/sections-editor/site-editor-toast.tsx b/apps/web/src/components/sections-editor/site-editor-toast.tsx new file mode 100644 index 0000000000..5233c03b09 --- /dev/null +++ b/apps/web/src/components/sections-editor/site-editor-toast.tsx @@ -0,0 +1,48 @@ +/** + * The site editor's toasts: one per user action. Each kind of action reuses + * one sonner id, so a retry, a burst of failing autosaves or a follow-up + * outcome replaces the toast on screen instead of stacking another one. + * Developer detail (an error's own text, a status code) sits behind a + * "Details" disclosure, never in the headline. + */ + +import { toast } from "sonner"; +import type { TFunction } from "@/i18n/use-t.ts"; +import type { ContentSource } from "./content-backend"; +import { saveErrorDetail, saveErrorMessage } from "./serve-save-error"; + +/** Every save failure in the site editor shares this toast. */ +export const SITE_EDITOR_SAVE_TOAST = "site-editor-save"; + +/** A collapsed "Details" line holding the developer detail of a failure. */ +function ErrorDetails({ label, detail }: { label: string; detail: string }) { + return ( +
+ + {label} + +

+ {detail} +

+
+ ); +} + +/** A toast `description` for `detail`, or nothing when there is none. */ +function errorDetailsDescription(t: TFunction, detail: string | null) { + return detail ? ( + + ) : undefined; +} + +/** A failed save, as one toast (replacing an earlier save failure's). */ +export function toastSaveError( + t: TFunction, + error: Error, + source: ContentSource | null, +) { + toast.error(saveErrorMessage(t, error, source), { + id: SITE_EDITOR_SAVE_TOAST, + description: errorDetailsDescription(t, saveErrorDetail(error, source)), + }); +} diff --git a/apps/web/src/components/sections-editor/use-save-block.ts b/apps/web/src/components/sections-editor/use-save-block.ts index 278c6dbeff..c0185c6e5e 100644 --- a/apps/web/src/components/sections-editor/use-save-block.ts +++ b/apps/web/src/components/sections-editor/use-save-block.ts @@ -6,7 +6,6 @@ import { useDecofileCacheKey } from "./use-decofile"; import { useContentBackend } from "./use-content-backend"; import { applyProtocolPatch } from "./content-protocol-api"; import { usePackagePath } from "./use-package-path"; -import { toast } from "sonner"; import { sanitizeSecretsForPersistence } from "@decocms/shared/decofile"; import { decoBlockFilePath } from "./deco-block-key"; import { decoRepoPath } from "./deco-repo-path"; @@ -22,7 +21,8 @@ import { useOptionalChatTask } from "@/components/chat/chat-context"; import { buildSandboxUrl } from "@/sdk/sandbox-url"; import { KEYS } from "@/lib/query-keys"; import { useT } from "@/i18n/use-t"; -import { isSaveConflict, saveErrorMessage } from "./serve-save-error"; +import { isSaveConflict } from "./serve-save-error"; +import { toastSaveError } from "./site-editor-toast"; /** Debounce window for form-driven block autosaves (ms). */ export const AUTOSAVE_DELAY = 700; @@ -70,8 +70,9 @@ export function useSaveBlock({ const backend = useContentBackend(virtualMcpId, branch); const protocol = backend.kind === "protocol" ? backend : null; const cacheKey = useDecofileCacheKey({ orgSlug, virtualMcpId, branch }); + const t = useT(); - return useMutation({ + const mutation = useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), // Serialize a branch's writes so overlapping autosaves can't land an older payload last, dropping a newer edit. scope: decofileWriteScope(orgSlug, virtualMcpId, branch), @@ -190,6 +191,13 @@ export function useSaveBlock({ ); }, }); + + return { + ...mutation, + /** One toast for a failed save, worded for this backend's editor. */ + reportError: (error: Error) => + toastSaveError(t, error, protocol?.source ?? null), + }; } /** @@ -217,7 +225,6 @@ export function useDebouncedSaveBlock( opts?: { onSaved?: () => void; guard?: SaveGuard }, ) { const saveBlock = useSaveBlock(params); - const t = useT(); const pendingRef = useRef>(new Map()); const timersRef = useRef>>( new Map(), @@ -233,7 +240,7 @@ export function useDebouncedSaveBlock( { blockKey, data: resolved, guard: opts?.guard }, { onSuccess: () => opts?.onSaved?.(), - onError: (err) => toast.error(saveErrorMessage(t, err)), + onError: saveBlock.reportError, }, ); }; diff --git a/apps/web/src/i18n/en/index.ts b/apps/web/src/i18n/en/index.ts index 0b07309de2..fa23f95b84 100644 --- a/apps/web/src/i18n/en/index.ts +++ b/apps/web/src/i18n/en/index.ts @@ -35,6 +35,7 @@ import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; import { chooseEditor } from "./choose-editor.ts"; import { decoServe } from "./deco-serve.ts"; +import { siteEditor } from "./site-editor.ts"; import { chat } from "./chat.ts"; import { credits } from "./credits.ts"; import { automations } from "./automations.ts"; @@ -85,6 +86,7 @@ export const en = { ...collections, ...chooseEditor, ...decoServe, + ...siteEditor, ...chat, ...credits, ...automations, diff --git a/apps/web/src/i18n/en/site-editor.ts b/apps/web/src/i18n/en/site-editor.ts new file mode 100644 index 0000000000..1ce4919223 --- /dev/null +++ b/apps/web/src/i18n/en/site-editor.ts @@ -0,0 +1,16 @@ +/** + * Site editor copy for business users: saving and publishing a site. Plain + * words only — never CDN, git, branch, commit, merge, pointer, revision or + * sha (`site-editor-copy.test.ts` enforces it). "Published" means live. + * Developer detail (an error's own text) goes behind `siteEditor.details`. + */ +export const siteEditor = { + "siteEditor.details": "Details", + "siteEditor.save.failed": "Couldn't save your change. Try again.", + "siteEditor.save.conflict": + "Not saved: someone else changed this at the same time. We're loading their version, so make your change again.", + "siteEditor.save.readOnly": "Not saved: this version can't be edited.", + "siteEditor.save.invalid": "Not saved: {detail}. Fix it and try again.", + "siteEditor.save.tooLarge": + "Not saved: this content is too large. Make it smaller and try again.", +}; diff --git a/apps/web/src/i18n/pt-br/index.ts b/apps/web/src/i18n/pt-br/index.ts index 52609d792f..920bf2bfbb 100644 --- a/apps/web/src/i18n/pt-br/index.ts +++ b/apps/web/src/i18n/pt-br/index.ts @@ -35,6 +35,7 @@ import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; import { chooseEditor } from "./choose-editor.ts"; import { decoServe } from "./deco-serve.ts"; +import { siteEditor } from "./site-editor.ts"; import { chat } from "./chat.ts"; import { credits } from "./credits.ts"; import { automations } from "./automations.ts"; @@ -83,6 +84,7 @@ export const ptBR = { ...collections, ...chooseEditor, ...decoServe, + ...siteEditor, ...chat, ...credits, ...automations, diff --git a/apps/web/src/i18n/pt-br/site-editor.ts b/apps/web/src/i18n/pt-br/site-editor.ts new file mode 100644 index 0000000000..8de443666f --- /dev/null +++ b/apps/web/src/i18n/pt-br/site-editor.ts @@ -0,0 +1,13 @@ +import type { siteEditor as siteEditorEn } from "../en/site-editor.ts"; + +export const siteEditor = { + "siteEditor.details": "Detalhes", + "siteEditor.save.failed": + "Não foi possível salvar sua alteração. Tente de novo.", + "siteEditor.save.conflict": + "Não salvo: outra pessoa alterou isto ao mesmo tempo. Estamos carregando a versão dela, então refaça sua alteração.", + "siteEditor.save.readOnly": "Não salvo: esta versão não pode ser editada.", + "siteEditor.save.invalid": "Não salvo: {detail}. Corrija e tente de novo.", + "siteEditor.save.tooLarge": + "Não salvo: este conteúdo é grande demais. Diminua o tamanho e tente de novo.", +} satisfies Record; diff --git a/apps/web/src/i18n/site-editor-copy.test.ts b/apps/web/src/i18n/site-editor-copy.test.ts new file mode 100644 index 0000000000..18c66225ad --- /dev/null +++ b/apps/web/src/i18n/site-editor-copy.test.ts @@ -0,0 +1,50 @@ +/** + * The site editor speaks to business users: its copy never names the + * machinery behind a save or a publish. "Published" means live. + */ + +import { describe, expect, test } from "bun:test"; +import { en } from "./en/index.ts"; +import { ptBR } from "./pt-br/index.ts"; + +/** Namespaces whose every string a business user reads in the site editor. */ +const SITE_EDITOR_PREFIXES = ["siteEditor."]; + +const BANNED: RegExp[] = [ + /\bCDN\b/i, + /\bmerge[ds]?\b/i, + /mesclad/i, + /\bcommit/i, + /\bgit\b/i, + /\bbranch/i, + /\bpointer\b/i, + /latest\.json/i, + /\brevision/i, + /\bsha\b/i, + /make current/i, + /tornar atual/i, +]; + +const siteEditorEntries = (dict: Record) => + Object.entries(dict).filter(([key]) => + SITE_EDITOR_PREFIXES.some((prefix) => key.startsWith(prefix)), + ); + +describe("site editor copy", () => { + for (const [name, dict] of [ + ["en", en], + ["pt-br", ptBR], + ] as const) { + test(`${name}: no developer jargon`, () => { + const entries = siteEditorEntries(dict as Record); + expect(entries.length).toBeGreaterThan(0); + for (const [key, value] of entries) { + for (const banned of BANNED) { + if (banned.test(value)) { + throw new Error(`${key} says ${banned}: "${value}"`); + } + } + } + }); + } +}); From c124bffe132ef7ff796edb9d718f2f01af12fc31 Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 9 Oct 2026 12:32:16 -0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(web):=20a=20refused=20save=20says=20"so?= =?UTF-8?q?me=20fields"=20in=20plain=20words;=20pt-br=20"N=C3=A3o=20foi=20?= =?UTF-8?q?salvo"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline no longer carries the server's validation text; Details names the fields to fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sections-editor/serve-save-error.test.ts | 5 +++-- .../sections-editor/serve-save-error.ts | 16 +++++++++++----- apps/web/src/i18n/en/site-editor.ts | 3 ++- apps/web/src/i18n/pt-br/site-editor.ts | 10 ++++++---- apps/web/src/i18n/site-editor-copy.test.ts | 3 ++- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/sections-editor/serve-save-error.test.ts b/apps/web/src/components/sections-editor/serve-save-error.test.ts index 3469465d26..e619cddf8f 100644 --- a/apps/web/src/components/sections-editor/serve-save-error.test.ts +++ b/apps/web/src/components/sections-editor/serve-save-error.test.ts @@ -45,15 +45,16 @@ describe("save messages for business users (hosted, sandbox, legacy)", () => { }); } - test("a refused block names what to fix", () => { + test("a refused block says to fix the fields; Details names them", () => { const error = new ContentProtocolError(ErrorCode.InvalidBlock, "invalid", { violations: [ { name: "Header", rule: "required", message: "title is required" }, ], }); expect(saveErrorMessage(t, error, "github")).toBe( - "Not saved: title is required. Fix it and try again.", + "Not saved: some fields aren't filled in correctly. Fix them and try again.", ); + expect(saveErrorDetail(error, "github")).toContain("title is required"); }); }); diff --git a/apps/web/src/components/sections-editor/serve-save-error.ts b/apps/web/src/components/sections-editor/serve-save-error.ts index 71ae24a4f9..24cbe6c7a7 100644 --- a/apps/web/src/components/sections-editor/serve-save-error.ts +++ b/apps/web/src/components/sections-editor/serve-save-error.ts @@ -56,9 +56,7 @@ export function saveErrorMessage( return t("siteEditor.save.readOnly"); case ErrorCode.InvalidBlock: case ErrorCode.InvalidParams: - return t("siteEditor.save.invalid", { - detail: violationsDetail(error), - }); + return t("siteEditor.save.invalid"); case ErrorCode.LimitExceeded: return t("siteEditor.save.tooLarge"); } @@ -76,8 +74,16 @@ export function saveErrorDetail( source: ContentSource | null, ): string | null { if (source === "local") return null; - const code = error instanceof ContentProtocolError ? `${error.code}: ` : ""; - return error.message ? `${code}${error.message}` : null; + if (error instanceof ContentProtocolError) { + // A refused block: which fields to fix, in the server's own words. + const message = + error.code === ErrorCode.InvalidBlock || + error.code === ErrorCode.InvalidParams + ? violationsDetail(error) + : error.message; + return message ? `${error.code}: ${message}` : null; + } + return error.message || null; } function localSaveErrorMessage(t: TFunction, error: Error): string { diff --git a/apps/web/src/i18n/en/site-editor.ts b/apps/web/src/i18n/en/site-editor.ts index 1ce4919223..9794583287 100644 --- a/apps/web/src/i18n/en/site-editor.ts +++ b/apps/web/src/i18n/en/site-editor.ts @@ -10,7 +10,8 @@ export const siteEditor = { "siteEditor.save.conflict": "Not saved: someone else changed this at the same time. We're loading their version, so make your change again.", "siteEditor.save.readOnly": "Not saved: this version can't be edited.", - "siteEditor.save.invalid": "Not saved: {detail}. Fix it and try again.", + "siteEditor.save.invalid": + "Not saved: some fields aren't filled in correctly. Fix them and try again.", "siteEditor.save.tooLarge": "Not saved: this content is too large. Make it smaller and try again.", }; diff --git a/apps/web/src/i18n/pt-br/site-editor.ts b/apps/web/src/i18n/pt-br/site-editor.ts index 8de443666f..ea0629746b 100644 --- a/apps/web/src/i18n/pt-br/site-editor.ts +++ b/apps/web/src/i18n/pt-br/site-editor.ts @@ -5,9 +5,11 @@ export const siteEditor = { "siteEditor.save.failed": "Não foi possível salvar sua alteração. Tente de novo.", "siteEditor.save.conflict": - "Não salvo: outra pessoa alterou isto ao mesmo tempo. Estamos carregando a versão dela, então refaça sua alteração.", - "siteEditor.save.readOnly": "Não salvo: esta versão não pode ser editada.", - "siteEditor.save.invalid": "Não salvo: {detail}. Corrija e tente de novo.", + "Não foi salvo: outra pessoa alterou isto ao mesmo tempo. Estamos carregando a versão dela, então refaça sua alteração.", + "siteEditor.save.readOnly": + "Não foi salvo: esta versão não pode ser editada.", + "siteEditor.save.invalid": + "Não foi salvo: alguns campos não estão preenchidos corretamente. Corrija e tente de novo.", "siteEditor.save.tooLarge": - "Não salvo: este conteúdo é grande demais. Diminua o tamanho e tente de novo.", + "Não foi salvo: este conteúdo é grande demais. Diminua o tamanho e tente de novo.", } satisfies Record; diff --git a/apps/web/src/i18n/site-editor-copy.test.ts b/apps/web/src/i18n/site-editor-copy.test.ts index 18c66225ad..6d1cd0fca3 100644 --- a/apps/web/src/i18n/site-editor-copy.test.ts +++ b/apps/web/src/i18n/site-editor-copy.test.ts @@ -13,7 +13,8 @@ const SITE_EDITOR_PREFIXES = ["siteEditor."]; const BANNED: RegExp[] = [ /\bCDN\b/i, /\bmerge[ds]?\b/i, - /mesclad/i, + /mescl/i, + /\brebase/i, /\bcommit/i, /\bgit\b/i, /\bbranch/i,