Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions apps/web/src/components/sections-editor/deco-serve-messages.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
});
});
210 changes: 210 additions & 0 deletions apps/web/src/components/sections-editor/deco-serve-notices.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<code
// oxlint-disable-next-line no-array-index-key -- static split of one sentence
key={index}
className="whitespace-nowrap rounded bg-muted px-1 py-px font-mono text-[0.85em] text-foreground"
>
{part}
</code>
) : (
// oxlint-disable-next-line no-array-index-key -- static split of one sentence
<Fragment key={index}>{part}</Fragment>
),
)}
</>
);
}

/** 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 (
<div className="flex items-center gap-1 rounded-lg border border-border bg-muted/60 py-1 pr-1 pl-3">
<code className="min-w-0 flex-1 overflow-x-auto py-1.5 font-mono text-xs leading-5 text-foreground">
<span aria-hidden="true" className="select-none text-muted-foreground">
${" "}
</span>
{command.split(" ").map((word, index) => (
<Fragment
// oxlint-disable-next-line no-array-index-key -- static split of one command, wrapping between words only
key={index}
>
{index > 0 && " "}
<span className="whitespace-nowrap">{word}</span>
</Fragment>
))}
</code>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 self-start"
aria-label={t("decoServe.guide.copy")}
onClick={() => void handleCopy(command).catch(() => {})}
>
{copied ? <Check className="text-success" /> : <Copy01 />}
<span aria-hidden="true">
{copied
? t("decoServe.guide.copied")
: t("decoServe.guide.copyShort")}
</span>
</Button>
<span className="sr-only" aria-live="polite">
{copied ? t("decoServe.guide.copiedAnnouncement") : ""}
</span>
</div>
);
}

type DocsLink = keyof typeof blocksDocs;

const DOCS_LABELS: Record<DocsLink, TranslationKey> = {
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 (
<nav
aria-label={t("decoServe.docs.heading")}
className={cn("flex flex-col gap-2 text-sm", className)}
>
<span className="text-muted-foreground">
{t("decoServe.docs.heading")}
</span>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{links.map((link) => (
<a
key={link}
href={blocksDocs[link]}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-foreground underline-offset-4 hover:underline focus-visible:underline"
>
{t(DOCS_LABELS[link])}
<LinkExternal01 aria-hidden="true" className="size-3.5" />
<span className="sr-only">{t("decoServe.docs.newTab")}</span>
</a>
))}
</div>
</nav>
);
}

/** 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 (
<Alert variant="warning">
<div className="flex min-w-0 flex-col gap-1">
<AlertTitle className="line-clamp-none text-foreground">
{title}
</AlertTitle>
<AlertDescription>
<RichCode text={body} />
</AlertDescription>
{children}
</div>
</Alert>
);
}
14 changes: 14 additions & 0 deletions apps/web/src/lib/blocks-docs.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 4 additions & 1 deletion knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
Loading