diff --git a/apps/web/src/components/sections-editor/deco-serve-messages.test.ts b/apps/web/src/components/sections-editor/deco-serve-messages.test.ts new file mode 100644 index 0000000000..a2577c97a5 --- /dev/null +++ b/apps/web/src/components/sections-editor/deco-serve-messages.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; +import { ContentProtocolError, ErrorCode } from "@decocms/blocks/protocol"; +import { en } from "@/i18n/en/index.ts"; +import { interpolate } from "@/i18n/interpolate.ts"; +import { ptBR } from "@/i18n/pt-br/index.ts"; +import type { TFunction } from "@/i18n/use-t.ts"; +import { + classifyServeProbeError, + NotDecoServeError, + type ServeProblem, +} from "./deco-serve-connection"; +import { serveProblemCopy, serveProblemShort } from "./deco-serve-notices"; +import { ServeLostError, saveErrorMessage } from "./serve-save-error"; + +const t: TFunction = (key, vars) => interpolate(en[key], vars); +const HOST = "localhost:4545"; + +/** Every failure a probe meets, and the message it leads to. */ +const CASES: [string, unknown, ServeProblem["reason"], RegExp][] = [ + [ + "a stopped server", + new TypeError("Failed to fetch"), + "not-answering", + /isn't answering/, + ], + [ + "an older, token-based deco serve", + new ContentProtocolError(ErrorCode.Unauthorized, "missing token"), + "outdated", + /out of date/, + ], + [ + "another major version", + new ContentProtocolError(ErrorCode.Unsupported, "speaks 2.x"), + "version-mismatch", + /don't match/, + ], + [ + "another program", + new NotDecoServeError(), + "not-deco-serve", + /Another program is using localhost:4545/, + ], + [ + "a server error", + new ContentProtocolError(ErrorCode.InternalError, "disk full"), + "error", + /couldn't open your content/, + ], +]; + +describe("connection messages", () => { + for (const [name, error, reason, title] of CASES) { + test(`${name} → ${reason}`, () => { + const problem = classifyServeProbeError(error); + expect(problem.reason).toBe(reason); + const copy = serveProblemCopy(t, problem, HOST); + expect(copy.title).toMatch(title); + // Every message names a next step, never protocol jargon. + expect(copy.body.length).toBeGreaterThan(40); + for (const jargon of [/\bRPC\b/i, /endpoint/i, /protocol/i]) { + expect(copy.title).not.toMatch(jargon); + expect(copy.body).not.toMatch(jargon); + } + expect(serveProblemShort(t, problem, HOST)).toContain( + reason === "not-answering" ? HOST : copy.title, + ); + }); + } + + test("an error keeps the server's own words for the details", () => { + expect( + serveProblemCopy(t, { reason: "error", detail: "disk full" }, HOST).body, + ).toContain('"disk full"'); + }); + + test("outdated and version mismatch say to update @decocms/blocks", () => { + for (const reason of ["outdated", "version-mismatch"] as const) { + expect(serveProblemCopy(t, { reason }, HOST).body).toContain( + "@decocms/blocks", + ); + } + }); +}); + +describe("save messages (a local deco serve)", () => { + test("each refusal says what to do", () => { + const cases: [Error, RegExp][] = [ + [ + new ContentProtocolError(ErrorCode.Conflict, "a precondition failed"), + /changed on your computer/, + ], + [ + new ContentProtocolError(ErrorCode.ReadOnly, "read-only"), + /read-only.*Restart it without that flag/, + ], + [ + new ContentProtocolError(ErrorCode.InvalidBlock, "invalid", { + violations: [ + { name: "Header", rule: "required", message: "title is required" }, + ], + }), + /rejected this content: title is required/, + ], + [ + new ContentProtocolError(ErrorCode.LimitExceeded, "too big"), + /larger than deco serve accepts/, + ], + [ + new ServeLostError(new TypeError("Failed to fetch")), + /stopped answering/, + ], + ]; + for (const [error, message] of cases) { + const text = saveErrorMessage(t, error, "local"); + expect(text).toMatch(/^Not saved: /); + expect(text).toMatch(message); + expect(text).not.toContain("Failed to fetch"); + } + }); + + test("any other failure keeps its own text", () => { + expect(saveErrorMessage(t, new Error("disk full"), "local")).toBe( + "Save failed: disk full", + ); + }); +}); + +describe("pt-br", () => { + test("translates every site editor connection message", () => { + const keys = Object.keys(en).filter((key) => key.startsWith("decoServe.")); + for (const key of keys) { + const translated = ptBR[key as keyof typeof ptBR]; + expect(translated).toBeTruthy(); + // Commands and flags stay as they are. + for (const code of en[key as keyof typeof en].match(/`[^`]+`/g) ?? []) { + expect(translated).toContain(code); + } + } + }); +}); diff --git a/apps/web/src/components/sections-editor/deco-serve-notices.tsx b/apps/web/src/components/sections-editor/deco-serve-notices.tsx new file mode 100644 index 0000000000..331fdc8271 --- /dev/null +++ b/apps/web/src/components/sections-editor/deco-serve-notices.tsx @@ -0,0 +1,210 @@ +/** + * The pieces the site editor explains a `deco serve` connection with: the + * command to copy, docs links, and one message per connection problem (what + * happened, why, and the one thing to do next). Shared by `/site-editor`'s + * guide, its connection gate, the "Local server" chip and the draft + * selector's Local tab. + */ + +import { Fragment, type ReactNode } from "react"; +import { Check, Copy01, LinkExternal01 } from "@untitledui/icons"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@decocms/ui/components/alert.tsx"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { useCopy } from "@decocms/ui/hooks/use-copy.ts"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import type { TFunction, TranslationKey } from "@/i18n/use-t.ts"; +import { useT } from "@/i18n/use-t.ts"; +import { blocksDocs } from "@/lib/blocks-docs"; +import type { ServeProblem } from "./deco-serve-connection"; + +/** A translated sentence whose `backticked` parts render as code. */ +export function RichCode({ text }: { text: string }) { + const parts = text.split("`"); + return ( + <> + {parts.map((part, index) => + index % 2 === 1 ? ( + + {part} + + ) : ( + // oxlint-disable-next-line no-array-index-key -- static split of one sentence + {part} + ), + )} + + ); +} + +/** A terminal command with a Copy button that says when it copied. */ +export function CommandSnippet({ command }: { command: string }) { + const t = useT(); + const { handleCopy, copied } = useCopy(); + return ( +
+ + + {command.split(" ").map((word, index) => ( + + {index > 0 && " "} + {word} + + ))} + + + + {copied ? t("decoServe.guide.copiedAnnouncement") : ""} + +
+ ); +} + +type DocsLink = keyof typeof blocksDocs; + +const DOCS_LABELS: Record = { + quickstart: "decoServe.docs.quickstart", + siteEditor: "decoServe.docs.siteEditor", + serve: "decoServe.docs.serve", + schema: "decoServe.docs.schema", + troubleshooting: "decoServe.docs.troubleshooting", +}; + +/** A row of links into the Blocks docs, each opening in a new tab. */ +export function DocsLinks({ + links, + className, +}: { + links: readonly DocsLink[]; + className?: string; +}) { + const t = useT(); + return ( + + ); +} + +/** Title and explanation of a server that can't be used. */ +export function serveProblemCopy( + t: TFunction, + problem: ServeProblem, + host: string, +): { title: string; body: string } { + switch (problem.reason) { + case "not-answering": + return { + title: t("decoServe.state.notAnswering.title"), + body: t("decoServe.state.notAnswering.short", { host }), + }; + case "outdated": + return { + title: t("decoServe.state.outdated.title"), + body: t("decoServe.state.outdated.body", { host }), + }; + case "version-mismatch": + return { + title: t("decoServe.state.versionMismatch.title"), + body: t("decoServe.state.versionMismatch.body", { host }), + }; + case "not-deco-serve": + return { + title: t("decoServe.state.notDecoServe.title", { host }), + body: t("decoServe.state.notDecoServe.body", { host }), + }; + case "error": + return { + title: t("decoServe.state.error.title"), + body: t("decoServe.state.error.body", { + detail: problem.detail ?? "", + }), + }; + } +} + +/** One line for tight spots (the chip's tooltip, the Local tab). */ +export function serveProblemShort( + t: TFunction, + problem: ServeProblem, + host: string, +): string { + if (problem.reason === "not-answering") { + return t("decoServe.state.notAnswering.short", { host }); + } + const { title, body } = serveProblemCopy(t, problem, host); + return `${title}. ${body}`; +} + +/** A problem as an Alert, with an optional action under it. */ +export function ServeProblemAlert({ + problem, + host, + children, +}: { + problem: ServeProblem; + host: string; + children?: ReactNode; +}) { + const t = useT(); + const { title, body } = serveProblemCopy(t, problem, host); + return ( + +
+ + {title} + + + + + {children} +
+
+ ); +} diff --git a/apps/web/src/lib/blocks-docs.ts b/apps/web/src/lib/blocks-docs.ts new file mode 100644 index 0000000000..6234d726be --- /dev/null +++ b/apps/web/src/lib/blocks-docs.ts @@ -0,0 +1,14 @@ +/** + * The Deco Blocks docs (deco-sites/docs-tanstack, published at docs.decocms.com). + * Every docs link the site editor shows is built from this one base, so a + * move changes only this line. + */ +const BLOCKS_DOCS_URL = "https://docs.decocms.com/storefront/blocks"; + +export const blocksDocs = { + quickstart: `${BLOCKS_DOCS_URL}/next/quickstart`, + siteEditor: `${BLOCKS_DOCS_URL}/next/site-editor#edit-on-your-machine`, + serve: `${BLOCKS_DOCS_URL}/next/cli#deco-serve`, + schema: `${BLOCKS_DOCS_URL}/next/cli#deco-schema-and-deco-content`, + troubleshooting: `${BLOCKS_DOCS_URL}/next/troubleshooting#site-editor`, +} as const; diff --git a/knip.jsonc b/knip.jsonc index 0cd921491c..01099d0048 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -86,6 +86,9 @@ "apps/web/src/components/sections-editor/content-protocol-api.ts": [ "exports" ], - "apps/web/src/hooks/use-new-blocks-editor.ts": ["exports"] + "apps/web/src/hooks/use-new-blocks-editor.ts": ["exports"], + "apps/web/src/components/sections-editor/deco-serve-notices.tsx": [ + "exports" + ] } }