From 4b95e4f8d82d3f887b2f623f7f1382eb81474655 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 14:57:22 -0300 Subject: [PATCH] feat(web): deco serve connection model + copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `deco serve` connection model: parsing the `/site-editor#endpoint=…` link, loopback-only endpoints, probe classification and retry backoff, plus its en/pt-br copy. Adds `@decocms/blocks` to apps/web (only the web hunk of bun.lock). Pure module with tests; nothing consumes it yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- apps/web/package.json | 1 + .../deco-serve-connection.test.ts | 365 ++++++++++++++++++ .../sections-editor/deco-serve-connection.ts | 266 +++++++++++++ apps/web/src/i18n/en/deco-serve.ts | 90 +++++ apps/web/src/i18n/en/index.ts | 2 + apps/web/src/i18n/pt-br/deco-serve.ts | 89 +++++ apps/web/src/i18n/pt-br/index.ts | 2 + bun.lock | 5 + knip.jsonc | 7 +- 9 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/sections-editor/deco-serve-connection.test.ts create mode 100644 apps/web/src/components/sections-editor/deco-serve-connection.ts create mode 100644 apps/web/src/i18n/en/deco-serve.ts create mode 100644 apps/web/src/i18n/pt-br/deco-serve.ts 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/src/components/sections-editor/deco-serve-connection.test.ts b/apps/web/src/components/sections-editor/deco-serve-connection.test.ts new file mode 100644 index 0000000000..2a6a02ca8d --- /dev/null +++ b/apps/web/src/components/sections-editor/deco-serve-connection.test.ts @@ -0,0 +1,365 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { ContentProtocolError, ErrorCode } from "@decocms/blocks/protocol"; +import { + classifyServeProbeError, + DEFAULT_SERVE_ENDPOINT, + NotDecoServeError, + parseServeAddress, + serveCandidates, + endpointHost, + isLoopbackEndpoint, + parseConnectFragment, + parseConnectLink, + parseStoredConnection, + probeRetryDelay, + readLastConnection, + saveLastConnection, +} from "./deco-serve-connection"; + +describe("parseConnectFragment", () => { + test("reads the endpoint deco serve prints", () => { + expect( + parseConnectFragment( + `#endpoint=${encodeURIComponent("http://localhost:4545/rpc")}`, + ), + ).toEqual({ endpoint: "http://localhost:4545/rpc" }); + }); + + test("reads an older link's 127.0.0.1 or [::1] endpoint as localhost", () => { + for (const legacy of [ + "http://127.0.0.1:4545/rpc", + "http://[::1]:4545/rpc", + ]) { + expect( + parseConnectFragment(`#endpoint=${encodeURIComponent(legacy)}`), + ).toEqual({ endpoint: "http://localhost:4545/rpc" }); + } + }); + + test("ignores a token left in an older link", () => { + expect( + parseConnectFragment( + `#endpoint=${encodeURIComponent("http://127.0.0.1:4545/rpc")}&token=abc123`, + ), + ).toEqual({ endpoint: "http://localhost:4545/rpc" }); + }); + + test("refuses incomplete or unsafe links", () => { + expect(parseConnectFragment("")).toBeNull(); + expect(parseConnectFragment("#token=abc")).toBeNull(); + expect(parseConnectFragment("#endpoint=javascript:alert(1)")).toBeNull(); + expect(parseConnectFragment("#endpoint=not a url")).toBeNull(); + expect( + parseConnectFragment(`#endpoint=http://127.0.0.1/${"x".repeat(2100)}`), + ).toBeNull(); + }); +}); + +describe("isLoopbackEndpoint", () => { + test("accepts only this machine", () => { + for (const endpoint of [ + "http://127.0.0.1:4545/rpc", + "http://localhost:4545/rpc", + "http://[::1]:4545/rpc", + ]) { + expect(isLoopbackEndpoint(endpoint)).toBe(true); + } + for (const endpoint of [ + "https://evil.example/rpc", + "http://127.0.0.1.evil.example/rpc", + "http://localhost.evil.example/rpc", + "https://site.localhost/rpc", + "http://10.0.0.2:4545/rpc", + "ftp://127.0.0.1/rpc", + "not a url", + ]) { + expect(isLoopbackEndpoint(endpoint)).toBe(false); + } + }); + + test("a connect link to another path is refused", () => { + expect( + parseConnectFragment("#endpoint=http://127.0.0.1:4545/other"), + ).toBeNull(); + expect(parseConnectFragment("#endpoint=http://localhost:4545/rpc")).toEqual( + { endpoint: "http://localhost:4545/rpc" }, + ); + }); + + test("a connect link to another host is refused", () => { + expect( + parseConnectFragment("#endpoint=https://evil.example/rpc"), + ).toBeNull(); + }); +}); + +describe("parseStoredConnection", () => { + test("accepts only a well-formed connection", () => { + expect( + parseStoredConnection({ endpoint: "http://localhost:1/rpc" }), + ).toEqual({ endpoint: "http://localhost:1/rpc" }); + expect(parseStoredConnection(null)).toBeNull(); + expect(parseStoredConnection("http://127.0.0.1:1/rpc")).toBeNull(); + expect(parseStoredConnection({ endpoint: "x" })).toBeNull(); + }); + + test("drops the token an older Studio stored", () => { + expect( + parseStoredConnection({ endpoint: "http://localhost:1/rpc", token: "t" }), + ).toEqual({ endpoint: "http://localhost:1/rpc" }); + }); + + test("migrates a 127.0.0.1 endpoint an older Studio stored to localhost", () => { + expect( + parseStoredConnection({ endpoint: "http://127.0.0.1:4547/rpc" }), + ).toEqual({ endpoint: "http://localhost:4547/rpc" }); + }); +}); + +describe("parseConnectLink", () => { + const endpoint = encodeURIComponent("http://127.0.0.1:4545/rpc"); + + test("reads a pasted site editor link", () => { + expect( + parseConnectLink( + ` https://studio.decocms.com/site-editor#endpoint=${endpoint} `, + ), + ).toEqual({ endpoint: "http://localhost:4545/rpc" }); + expect( + parseConnectLink( + `https://studio.decocms.com/site-editor#endpoint=${endpoint}&token=t1`, + ), + ).toEqual({ endpoint: "http://localhost:4545/rpc" }); + }); + + test("anything else is not a connect link", () => { + expect(parseConnectLink("https://my-tunnel.example.com")).toBeNull(); + expect(parseConnectLink("http://localhost:8000")).toBeNull(); + expect(parseConnectLink("localhost:8000")).toBeNull(); + expect(parseConnectLink("")).toBeNull(); + }); +}); + +describe("last connection", () => { + const original = globalThis.localStorage; + const fakeStorage = () => { + const map = new Map(); + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + } as unknown as Storage; + }; + afterEach(() => { + Object.defineProperty(globalThis, "localStorage", { + value: original, + configurable: true, + }); + }); + + test("remembers the last endpoint per browser", () => { + Object.defineProperty(globalThis, "localStorage", { + value: fakeStorage(), + configurable: true, + }); + expect(readLastConnection()).toBeNull(); + saveLastConnection({ endpoint: "http://localhost:4545/rpc" }); + expect(readLastConnection()).toEqual({ + endpoint: "http://localhost:4545/rpc", + }); + }); + + test("reads a remembered 127.0.0.1 endpoint as localhost, the same server", () => { + Object.defineProperty(globalThis, "localStorage", { + value: fakeStorage(), + configurable: true, + }); + localStorage.setItem( + "studio:deco-serve-last", + JSON.stringify({ endpoint: "http://127.0.0.1:4545/rpc" }), + ); + expect(readLastConnection()).toEqual({ endpoint: DEFAULT_SERVE_ENDPOINT }); + expect(serveCandidates(null, readLastConnection())).toEqual([ + DEFAULT_SERVE_ENDPOINT, + ]); + }); + + test("blocked storage reads as nothing remembered", () => { + Object.defineProperty(globalThis, "localStorage", { + get() { + throw new Error("blocked"); + }, + configurable: true, + }); + expect(() => + saveLastConnection({ endpoint: "http://127.0.0.1:4545/rpc" }), + ).not.toThrow(); + expect(readLastConnection()).toBeNull(); + }); +}); + +describe("endpointHost", () => { + test("names where the server listens", () => { + expect(endpointHost("http://localhost:4545/rpc")).toBe("localhost:4545"); + expect(endpointHost("http://127.0.0.1:4545/rpc")).toBe("localhost:4545"); + expect(endpointHost("http://[::1]:4545/rpc")).toBe("localhost:4545"); + }); +}); + +describe("probeRetryDelay", () => { + test("backs off from 1s to at most 10s", () => { + expect([1, 2, 3, 4, 5, 9].map((n) => probeRetryDelay(n))).toEqual([ + 1_000, 2_000, 4_000, 8_000, 10_000, 10_000, + ]); + }); + + test("takes another cap", () => { + expect([4, 5, 9].map((n) => probeRetryDelay(n, 15_000))).toEqual([ + 8_000, 15_000, 15_000, + ]); + }); +}); + +describe("parseServeAddress", () => { + const ok = (endpoint: string) => ({ + ok: true as const, + connection: { endpoint }, + }); + + test("accepts the full Site editor link deco serve prints", () => { + expect( + parseServeAddress( + `http://localhost:4000/site-editor#endpoint=${encodeURIComponent("http://127.0.0.1:4547/rpc")}`, + ), + ).toEqual(ok("http://localhost:4547/rpc")); + }); + + test("accepts just a port", () => { + expect(parseServeAddress(" 4547 ")).toEqual( + ok("http://localhost:4547/rpc"), + ); + }); + + test("accepts an address, with or without the scheme and /rpc", () => { + expect(parseServeAddress("127.0.0.1:4547")).toEqual( + ok("http://localhost:4547/rpc"), + ); + expect(parseServeAddress("localhost:4548")).toEqual( + ok("http://localhost:4548/rpc"), + ); + expect(parseServeAddress("http://127.0.0.1:4550/rpc")).toEqual( + ok("http://localhost:4550/rpc"), + ); + expect(parseServeAddress("http://[::1]:4550/")).toEqual( + ok("http://localhost:4550/rpc"), + ); + }); + + test("rejects addresses off this machine as not local", () => { + expect(parseServeAddress("example.com:4545")).toEqual({ + ok: false, + reason: "not-local", + }); + expect(parseServeAddress("https://evil.example/rpc")).toEqual({ + ok: false, + reason: "not-local", + }); + expect( + parseServeAddress( + `https://studio.decocms.com/site-editor#endpoint=${encodeURIComponent("https://evil.example/rpc")}`, + ), + ).toEqual({ ok: false, reason: "not-local" }); + }); + + test("rejects junk as unrecognized", () => { + for (const junk of [ + "", + " ", + "hello world", + "deco serve", + "99999", + "0", + "localhost", + "127.0.0.1:4545/some/page", + "javascript:alert(1)", + ]) { + expect(parseServeAddress(junk)).toEqual({ + ok: false, + reason: "unrecognized", + }); + } + }); +}); + +describe("classifyServeProbeError", () => { + test("no answer: a network error, an abort or a timeout", () => { + expect(classifyServeProbeError(new TypeError("Failed to fetch"))).toEqual({ + reason: "not-answering", + }); + expect( + classifyServeProbeError(new DOMException("aborted", "AbortError")), + ).toEqual({ reason: "not-answering" }); + }); + + test("an older deco serve asks for a token", () => { + expect( + classifyServeProbeError( + new ContentProtocolError( + ErrorCode.Unauthorized, + "missing or invalid bearer token", + ), + ), + ).toEqual({ reason: "outdated" }); + }); + + test("another major version", () => { + expect( + classifyServeProbeError( + new ContentProtocolError(ErrorCode.Unsupported, "speaks 2.x"), + ), + ).toEqual({ reason: "version-mismatch" }); + }); + + test("another program on the port", () => { + expect(classifyServeProbeError(new NotDecoServeError())).toEqual({ + reason: "not-deco-serve", + }); + expect( + classifyServeProbeError( + new ContentProtocolError( + ErrorCode.Unavailable, + "the endpoint answered HTTP 404 without a JSON-RPC body", + ), + ), + ).toEqual({ reason: "not-deco-serve" }); + }); + + test("any other error keeps the server's message", () => { + expect( + classifyServeProbeError( + new ContentProtocolError(ErrorCode.InternalError, "disk full"), + ), + ).toEqual({ reason: "error", detail: "disk full" }); + }); +}); + +describe("serveCandidates", () => { + test("the link's endpoint wins", () => { + expect( + serveCandidates( + { endpoint: "http://127.0.0.1:4547/rpc" }, + { endpoint: "http://127.0.0.1:4548/rpc" }, + ), + ).toEqual(["http://localhost:4547/rpc"]); + }); + + test("with no link, the remembered server, then the default port, without repeats", () => { + expect(serveCandidates(null, null)).toEqual([DEFAULT_SERVE_ENDPOINT]); + expect( + serveCandidates(null, { endpoint: "http://127.0.0.1:4547/rpc" }), + ).toEqual(["http://localhost:4547/rpc", DEFAULT_SERVE_ENDPOINT]); + expect(serveCandidates(null, { endpoint: DEFAULT_SERVE_ENDPOINT })).toEqual( + [DEFAULT_SERVE_ENDPOINT], + ); + }); +}); diff --git a/apps/web/src/components/sections-editor/deco-serve-connection.ts b/apps/web/src/components/sections-editor/deco-serve-connection.ts new file mode 100644 index 0000000000..f0120bf225 --- /dev/null +++ b/apps/web/src/components/sections-editor/deco-serve-connection.ts @@ -0,0 +1,266 @@ +/** + * A connection to `deco serve`, the content-protocol server the Blocks CLI + * runs on the editor's machine. The CLI prints a link, + * `/site-editor#endpoint=`, and nothing else: `deco serve` has + * no token and answers any origin. + * A `token=` left in an older link is ignored. Signed in, the same link + * pasted into the draft selector's "Local" option connects a project to it. + * + * Only loopback endpoints are accepted: a link pointing anywhere else would + * send the editor's edits, uploads and secrets (encrypted to that server's + * key) to whoever wrote the link. `deco serve` listens on 127.0.0.1 and ::1 + * and prints `localhost`; an endpoint on 127.0.0.1 or [::1] (an older link, + * or one remembered by an older Studio) is read as `localhost`, so it shows + * and compares the same. + */ + +import { ContentProtocolError, ErrorCode } from "@decocms/blocks/protocol"; +import { z } from "zod"; + +/** `deco serve` listens on this machine only (the hosts its Host check accepts). */ +export function isLoopbackEndpoint(value: string): boolean { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + const host = url.hostname.toLowerCase(); + return host === "127.0.0.1" || host === "[::1]" || host === "localhost"; + } catch { + return false; + } +} + +/** A loopback endpoint on `localhost`: `http://127.0.0.1:4545/rpc` → `http://localhost:4545/rpc`. */ +function toLocalhost(endpoint: string): string { + try { + const url = new URL(endpoint); + if (url.hostname === "127.0.0.1" || url.hostname === "[::1]") { + url.hostname = "localhost"; + } + return url.href; + } catch { + return endpoint; + } +} + +const DecoServeConnectionSchema = z.object({ + /** The protocol endpoint, such as `http://localhost:4545/rpc`. */ + endpoint: z + .string() + .max(2048) + .refine( + (value) => + isLoopbackEndpoint(value) && new URL(value).pathname === "/rpc", + ) + .transform(toLocalhost), +}); + +export type DecoServeConnection = z.infer; + +/** Reads a connect link's fragment; `null` when it isn't a valid one. */ +export function parseConnectFragment(hash: string): DecoServeConnection | null { + const params = new URLSearchParams(hash.replace(/^#/, "")); + const parsed = DecoServeConnectionSchema.safeParse({ + endpoint: params.get("endpoint") ?? "", + }); + return parsed.success ? parsed.data : null; +} + +/** + * A stored connection, or `null` for anything else in storage. A `token` + * saved by an older Studio is dropped. + */ +export function parseStoredConnection( + value: unknown, +): DecoServeConnection | null { + const parsed = DecoServeConnectionSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +/** + * A whole connect link pasted somewhere (the draft selector's "Local" + * option): its fragment's connection, or `null` for any other text. + */ +export function parseConnectLink(value: string): DecoServeConnection | null { + try { + return parseConnectFragment(new URL(value.trim()).hash); + } catch { + return null; + } +} + +const LAST_KEY = "studio:deco-serve-last"; + +/** + * Remembers `/site-editor`'s last link endpoint in this browser, so opening + * `/site-editor` with no link looks for it too. + */ +export function saveLastConnection(connection: DecoServeConnection): void { + try { + localStorage.setItem(LAST_KEY, JSON.stringify(connection)); + } catch { + // Storage blocked: `/site-editor` then needs the link again. + } +} + +export function readLastConnection(): DecoServeConnection | null { + try { + const raw = localStorage.getItem(LAST_KEY); + return raw ? parseStoredConnection(JSON.parse(raw)) : null; + } catch { + return null; + } +} + +/** `localhost:4545`: where the endpoint's server listens, for messages. */ +export function endpointHost(endpoint: string): string { + try { + return new URL(toLocalhost(endpoint)).host; + } catch { + return endpoint; + } +} + +/** Probe retries back off from 1s to `capMs` while `deco serve` is down. */ +export function probeRetryDelay(failures: number, capMs = 10_000): number { + return Math.min(1_000 * 2 ** Math.max(0, failures - 1), capMs); +} + +/** Where `deco serve` listens when it isn't given `--port`. */ +export const DEFAULT_SERVE_ENDPOINT = "http://localhost:4545/rpc"; + +/** The command that starts `deco serve` (the `deco` bin of `@decocms/blocks`). */ +export const SERVE_COMMAND = "npx @decocms/blocks serve"; + +/** The command that writes `.deco/schema.gen.json`. */ +export const SCHEMA_COMMAND = "npx @decocms/blocks schema"; + +export type ParsedServeAddress = + | { ok: true; connection: DecoServeConnection } + | { ok: false; reason: "not-local" | "unrecognized" }; + +/** + * What someone pasted into the draft selector's "Local" option: the Site + * editor link `deco serve` printed, an address (`localhost:4547`, `127.0.0.1:4547`, + * `http://localhost:4547/rpc`) or just a port (`4547`). Always resolves to + * the server's `/rpc` endpoint on `localhost`; only this machine is accepted. + */ +export function parseServeAddress(input: string): ParsedServeAddress { + const value = input.trim(); + if (!value) return { ok: false, reason: "unrecognized" }; + const fromLink = parseConnectLink(value); + if (fromLink) return { ok: true, connection: fromLink }; + if (/^\d{1,5}$/.test(value)) { + const port = Number(value); + if (port < 1 || port > 65_535) return { ok: false, reason: "unrecognized" }; + return { + ok: true, + connection: { endpoint: `http://localhost:${port}/rpc` }, + }; + } + let url: URL; + try { + url = new URL( + /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `http://${value}`, + ); + } catch { + return { ok: false, reason: "unrecognized" }; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { ok: false, reason: "unrecognized" }; + } + // A Site editor link whose endpoint isn't a usable one. + const linked = new URLSearchParams(url.hash.replace(/^#/, "")).get( + "endpoint", + ); + if (linked !== null) { + return { + ok: false, + reason: isLoopbackEndpoint(linked) ? "unrecognized" : "not-local", + }; + } + if (!isLoopbackEndpoint(url.href)) { + return { + ok: false, + reason: /[.:]/.test(url.host) ? "not-local" : "unrecognized", + }; + } + if (url.pathname !== "/" && url.pathname !== "/rpc") { + return { ok: false, reason: "unrecognized" }; + } + if (!url.port) return { ok: false, reason: "unrecognized" }; + return { + ok: true, + connection: { endpoint: toLocalhost(`${url.protocol}//${url.host}/rpc`) }, + }; +} + +/** + * Why a `deco serve` endpoint can't be used, in the words the site editor + * explains it with: + * - `not-answering`: nothing answered (stopped, restarting, another port, or a + * Chrome permission not granted, which a browser can't tell apart): + * `/site-editor` shows its guide and keeps looking; + * - `outdated`: an older `deco serve`, which asked for an access token; + * - `version-mismatch`: a `deco serve` of another major protocol version; + * - `not-deco-serve`: another program answered on that port; + * - `error`: `deco serve` answered with an error (`detail`). + */ +export type ServeProblemReason = + | "not-answering" + | "outdated" + | "version-mismatch" + | "not-deco-serve" + | "error"; + +export interface ServeProblem { + reason: ServeProblemReason; + /** The server's own message, for `error`. */ + detail?: string; +} + +/** Thrown by a probe whose server answered, but isn't `deco serve`. */ +export class NotDecoServeError extends Error { + constructor(message = "the endpoint isn't deco serve") { + super(message); + this.name = "NotDecoServeError"; + } +} + +/** Sorts a failed probe into the reason the site editor explains. */ +export function classifyServeProbeError(error: unknown): ServeProblem { + if (error instanceof NotDecoServeError) return { reason: "not-deco-serve" }; + if (!(error instanceof ContentProtocolError)) { + // `TypeError: Failed to fetch`, an abort or a timeout. + return { reason: "not-answering" }; + } + switch (error.code) { + case ErrorCode.Unauthorized: + return { reason: "outdated" }; + case ErrorCode.Unsupported: + return { reason: "version-mismatch" }; + case ErrorCode.Unavailable: + // The client's own: an HTTP answer without a JSON-RPC body. + if (/^the endpoint answered HTTP/.test(error.message)) { + return { reason: "not-deco-serve" }; + } + return { reason: "error", detail: error.message }; + default: + return { reason: "error", detail: error.message }; + } +} + +/** + * Where `/site-editor` looks for `deco serve`: the link's endpoint when it + * was opened with one (it wins), otherwise the last one used, then the + * default port. + */ +export function serveCandidates( + fromLink: DecoServeConnection | null, + remembered: DecoServeConnection | null, +): string[] { + if (fromLink) return [toLocalhost(fromLink.endpoint)]; + const all = [remembered?.endpoint, DEFAULT_SERVE_ENDPOINT] + .filter((endpoint): endpoint is string => !!endpoint) + .map(toLocalhost); + return [...new Set(all)]; +} diff --git a/apps/web/src/i18n/en/deco-serve.ts b/apps/web/src/i18n/en/deco-serve.ts new file mode 100644 index 0000000000..2b600db707 --- /dev/null +++ b/apps/web/src/i18n/en/deco-serve.ts @@ -0,0 +1,90 @@ +/** + * The site editor's local connection (`/site-editor` and the "Local" draft + * option): what `deco serve` is doing and the one thing to do next. Text in + * backticks renders as code (`RichCode`); commands and flags stay in English. + */ +export const decoServe = { + "decoServe.chip.label": "Local server", + "decoServe.chip.tooltip": + "Editing the files on this computer through deco serve. Nothing is committed: you review the changes and commit them yourself.", + "decoServe.chip.readOnly": "Read-only", + "decoServe.chip.readOnlyTooltip": + "deco serve was started with `--read-only`, so changes can't be saved. Restart it without that flag to edit.", + "decoServe.connect.reaching": "Connecting to deco serve on {host}…", + + "decoServe.guide.title": "Edit your site's content on your computer", + "decoServe.guide.lead": + "The site editor turns the content files of a site on this computer into forms. Changes are saved to those files, and nothing is committed: you review them and commit them yourself.", + "decoServe.guide.step1.title": "Start deco serve in your site's folder", + "decoServe.guide.step1.body": + "In a terminal, in your site's folder (the one that has the `.deco` folder), run:", + "decoServe.guide.step1.preview": + "The Preview tab shows your app on the port in your Vite config, or localhost:5173. If it runs somewhere else, add `--preview` with its address, such as `--preview localhost:3000`.", + "decoServe.guide.copy": "Copy command", + "decoServe.guide.copyShort": "Copy", + "decoServe.guide.copied": "Copied", + "decoServe.guide.copiedAnnouncement": "Command copied to the clipboard", + "decoServe.guide.step2.title": "This page connects on its own", + "decoServe.guide.step2.body": + "Leave this tab open. The editor opens here as soon as deco serve starts, with no link to click.", + "decoServe.guide.step2.looking": "Looking for deco serve on {hosts}…", + "decoServe.guide.step2.paused": + "Paused while this tab is in the background. It looks again when you come back.", + "decoServe.guide.step2.start": "Look for deco serve on this computer", + "decoServe.guide.v7": + "Working on an older Deco site (deco.cx or @decocms/start)? deco serve is for Blocks v8 sites. Open the site from its Studio project and choose Local in the draft selector.", + "decoServe.guide.checkingFirst": "Looking for deco serve…", + + "decoServe.docs.heading": "Learn more", + "decoServe.docs.quickstart": "Quickstart", + "decoServe.docs.siteEditor": "Site editor guide", + "decoServe.docs.serve": "deco serve options", + "decoServe.docs.schema": "deco schema", + "decoServe.docs.troubleshooting": "Troubleshooting", + "decoServe.docs.newTab": "(opens in a new tab)", + + "decoServe.link.invalidTitle": + "This link doesn't point to deco serve on your computer", + "decoServe.link.invalidBody": + "Site editor links only open a deco serve running on this computer (localhost). Copy the Site editor link again from your terminal, or follow the steps below.", + "decoServe.lna.deniedTitle": + "Chrome is blocking Studio from reaching your computer", + "decoServe.lna.deniedBody": + "Studio needs permission to connect to deco serve on this computer. Click the icon at the left of the address bar, allow access to apps on this device (local network access), then reload this page.", + + "decoServe.state.notAnswering.title": "deco serve isn't answering", + "decoServe.state.notAnswering.short": + "deco serve on {host} isn't answering. Reconnecting on its own when it's back.", + "decoServe.state.outdated.title": "This deco serve is out of date", + "decoServe.state.outdated.body": + "The server on {host} asks for an access token, as older versions of deco serve did. Update @decocms/blocks in your site to the latest version, then start deco serve again.", + "decoServe.state.versionMismatch.title": + "This Studio and deco serve don't match", + "decoServe.state.versionMismatch.body": + "deco serve on {host} comes from a different major version of Blocks than this Studio, so they can't edit content together. Update @decocms/blocks in your site and restart deco serve.", + "decoServe.state.notDecoServe.title": "Another program is using {host}", + "decoServe.state.notDecoServe.body": + "Something other than deco serve answered on {host}. Start deco serve on another port with `--port 4546` and open the Site editor link it prints.", + "decoServe.state.error.title": "deco serve couldn't open your content", + "decoServe.state.error.body": + 'It answered, but with an error: "{detail}". Check the terminal where deco serve runs for details, then try again.', + + "decoServe.save.conflict": + "Not saved: this content changed on your computer after you opened it (in your code editor or by git, for example). Studio is loading the latest version. Make your change again on top of it.", + "decoServe.save.readOnly": + "Not saved: deco serve is read-only (started with --read-only). Restart it without that flag to save.", + "decoServe.save.invalid": + "Not saved: deco serve rejected this content: {detail}", + "decoServe.save.tooLarge": + "Not saved: this content is larger than deco serve accepts. Make it smaller and try again.", + "decoServe.save.serverGone": + "Not saved: deco serve stopped answering. Start it again, then repeat the change.", + "decoServe.upload.failed": "Couldn't upload {name}: {detail}", + "decoServe.upload.serverGone": + "Couldn't upload {name}: deco serve stopped answering. Start it again and retry.", + "decoServe.upload.readOnly": + "Couldn't upload {name}: deco serve is read-only (started with --read-only).", + + "decoServe.version.v8": + "Blocks v8: Studio edits your site's content files directly, without running your site's code.", +} as const; diff --git a/apps/web/src/i18n/en/index.ts b/apps/web/src/i18n/en/index.ts index 0cdef207ed..0b07309de2 100644 --- a/apps/web/src/i18n/en/index.ts +++ b/apps/web/src/i18n/en/index.ts @@ -34,6 +34,7 @@ import { common } from "./common.ts"; import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; import { chooseEditor } from "./choose-editor.ts"; +import { decoServe } from "./deco-serve.ts"; import { chat } from "./chat.ts"; import { credits } from "./credits.ts"; import { automations } from "./automations.ts"; @@ -83,6 +84,7 @@ export const en = { ...reportsOnboarding, ...collections, ...chooseEditor, + ...decoServe, ...chat, ...credits, ...automations, diff --git a/apps/web/src/i18n/pt-br/deco-serve.ts b/apps/web/src/i18n/pt-br/deco-serve.ts new file mode 100644 index 0000000000..f4e178c3d0 --- /dev/null +++ b/apps/web/src/i18n/pt-br/deco-serve.ts @@ -0,0 +1,89 @@ +import type { decoServe as decoServeEn } from "../en/deco-serve.ts"; + +export const decoServe = { + "decoServe.chip.label": "Servidor local", + "decoServe.chip.tooltip": + "Editando os arquivos deste computador pelo deco serve. Nada é commitado: você revisa as mudanças e faz o commit.", + "decoServe.chip.readOnly": "Somente leitura", + "decoServe.chip.readOnlyTooltip": + "O deco serve foi iniciado com `--read-only`, então as mudanças não podem ser salvas. Reinicie sem essa opção para editar.", + "decoServe.connect.reaching": "Conectando ao deco serve em {host}…", + + "decoServe.guide.title": "Edite o conteúdo do seu site no seu computador", + "decoServe.guide.lead": + "O editor do site transforma os arquivos de conteúdo de um site neste computador em formulários. As mudanças são salvas nesses arquivos e nada é commitado: você revisa e faz o commit.", + "decoServe.guide.step1.title": "Inicie o deco serve na pasta do seu site", + "decoServe.guide.step1.body": + "Em um terminal, na pasta do seu site (a que tem a pasta `.deco`), rode:", + "decoServe.guide.step1.preview": + "A aba Preview mostra seu app na porta da configuração do Vite, ou em localhost:5173. Se ele roda em outro endereço, adicione `--preview` com esse endereço, como `--preview localhost:3000`.", + "decoServe.guide.copy": "Copiar comando", + "decoServe.guide.copyShort": "Copiar", + "decoServe.guide.copied": "Copiado", + "decoServe.guide.copiedAnnouncement": + "Comando copiado para a área de transferência", + "decoServe.guide.step2.title": "Esta página se conecta sozinha", + "decoServe.guide.step2.body": + "Deixe esta aba aberta. O editor abre aqui assim que o deco serve iniciar, sem precisar clicar em link nenhum.", + "decoServe.guide.step2.looking": "Procurando o deco serve em {hosts}…", + "decoServe.guide.step2.paused": + "Pausado enquanto esta aba está em segundo plano. A busca recomeça quando você voltar.", + "decoServe.guide.step2.start": "Procurar o deco serve neste computador", + "decoServe.guide.v7": + "Trabalhando em um site Deco mais antigo (deco.cx ou @decocms/start)? O deco serve é para sites Blocks v8. Abra o site pelo projeto dele no Studio e escolha Local no seletor de rascunhos.", + "decoServe.guide.checkingFirst": "Procurando o deco serve…", + + "decoServe.docs.heading": "Saiba mais", + "decoServe.docs.quickstart": "Primeiros passos", + "decoServe.docs.siteEditor": "Guia do editor do site", + "decoServe.docs.serve": "Opções do deco serve", + "decoServe.docs.schema": "deco schema", + "decoServe.docs.troubleshooting": "Solução de problemas", + "decoServe.docs.newTab": "(abre em uma nova aba)", + + "decoServe.link.invalidTitle": + "Este link não aponta para o deco serve no seu computador", + "decoServe.link.invalidBody": + "Links do editor do site só abrem um deco serve rodando neste computador (localhost). Copie de novo o link do Site editor no terminal, ou siga os passos abaixo.", + "decoServe.lna.deniedTitle": + "O Chrome está impedindo o Studio de acessar seu computador", + "decoServe.lna.deniedBody": + "O Studio precisa de permissão para se conectar ao deco serve neste computador. Clique no ícone à esquerda da barra de endereço, permita o acesso a apps neste dispositivo (acesso à rede local) e recarregue esta página.", + + "decoServe.state.notAnswering.title": "O deco serve não está respondendo", + "decoServe.state.notAnswering.short": + "O deco serve em {host} não está respondendo. A reconexão é automática quando ele voltar.", + "decoServe.state.outdated.title": "Este deco serve está desatualizado", + "decoServe.state.outdated.body": + "O servidor em {host} pede um token de acesso, como as versões antigas do deco serve faziam. Atualize o @decocms/blocks do seu site para a versão mais recente e inicie o deco serve de novo.", + "decoServe.state.versionMismatch.title": + "Este Studio e o deco serve não são compatíveis", + "decoServe.state.versionMismatch.body": + "O deco serve em {host} é de uma versão principal do Blocks diferente da deste Studio, então os dois não conseguem editar o conteúdo juntos. Atualize o @decocms/blocks do seu site e reinicie o deco serve.", + "decoServe.state.notDecoServe.title": "Outro programa está usando {host}", + "decoServe.state.notDecoServe.body": + "Algo que não é o deco serve respondeu em {host}. Inicie o deco serve em outra porta com `--port 4546` e abra o link do Site editor que ele mostra.", + "decoServe.state.error.title": + "O deco serve não conseguiu abrir seu conteúdo", + "decoServe.state.error.body": + 'Ele respondeu, mas com um erro: "{detail}". Veja os detalhes no terminal onde o deco serve está rodando e tente de novo.', + + "decoServe.save.conflict": + "Não salvo: este conteúdo mudou no seu computador depois que você o abriu (no editor de código ou pelo git, por exemplo). O Studio está carregando a versão mais recente. Refaça sua mudança sobre ela.", + "decoServe.save.readOnly": + "Não salvo: o deco serve está em somente leitura (iniciado com --read-only). Reinicie sem essa opção para salvar.", + "decoServe.save.invalid": + "Não salvo: o deco serve recusou este conteúdo: {detail}", + "decoServe.save.tooLarge": + "Não salvo: este conteúdo é maior do que o deco serve aceita. Diminua o tamanho e tente de novo.", + "decoServe.save.serverGone": + "Não salvo: o deco serve parou de responder. Inicie de novo e repita a mudança.", + "decoServe.upload.failed": "Não foi possível enviar {name}: {detail}", + "decoServe.upload.serverGone": + "Não foi possível enviar {name}: o deco serve parou de responder. Inicie de novo e tente outra vez.", + "decoServe.upload.readOnly": + "Não foi possível enviar {name}: o deco serve está em somente leitura (iniciado com --read-only).", + + "decoServe.version.v8": + "Blocks v8: o Studio edita os arquivos de conteúdo do seu site diretamente, sem rodar o código do site.", +} satisfies Record; diff --git a/apps/web/src/i18n/pt-br/index.ts b/apps/web/src/i18n/pt-br/index.ts index d2c0e0e214..52609d792f 100644 --- a/apps/web/src/i18n/pt-br/index.ts +++ b/apps/web/src/i18n/pt-br/index.ts @@ -34,6 +34,7 @@ import { common } from "./common.ts"; import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; import { chooseEditor } from "./choose-editor.ts"; +import { decoServe } from "./deco-serve.ts"; import { chat } from "./chat.ts"; import { credits } from "./credits.ts"; import { automations } from "./automations.ts"; @@ -81,6 +82,7 @@ export const ptBR = { ...reportsOnboarding, ...collections, ...chooseEditor, + ...decoServe, ...chat, ...credits, ...automations, diff --git a/bun.lock b/bun.lock index 4211163fea..be3d5f395f 100644 --- a/bun.lock +++ b/bun.lock @@ -111,6 +111,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:*", @@ -704,6 +705,8 @@ "@decocms/bindings": ["@decocms/bindings@workspace:packages/bindings"], + "@decocms/blocks": ["@decocms/blocks@8.1.0-next.7", "", { "dependencies": { "ajv": "^8.20.0", "zod": "^4.4.3" }, "peerDependencies": { "react": "^19.0.0", "typescript": "^5.0.0" }, "bin": { "deco": "bin/deco.js" } }, "sha512-ULmBDjpBCjM/w0HikMtgZXHYA46WANE8wvqSbcvfaFGhV8CrpCAtAbSEB8ya/IfOYGd4sVhAFlz+CNarzTBZIA=="], + "@decocms/e2e": ["@decocms/e2e@workspace:packages/e2e"], "@decocms/harness-runner": ["@decocms/harness-runner@workspace:packages/harness-runner"], @@ -3660,6 +3663,8 @@ "@decocms/better-auth/better-call": ["better-call@1.1.5", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-nQJ3S87v6wApbDwbZ++FrQiSiVxWvZdjaO+2v6lZJAG2WWggkB2CziUDjPciz3eAt9TqfRursIQMZIcpkBnvlw=="], + "@decocms/blocks/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@decocms/native/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@decocms/sandbox-controller/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/knip.jsonc b/knip.jsonc index 648d876dea..75a902914c 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -76,6 +76,11 @@ "ignoreDependencies": ["@types/*"], // TODO(@camudo): Remove this config once I fix all unused type exports "ignoreIssues": { - "**/*": ["types"] + "**/*": ["types"], + // Temporary while the v8 stack lands: each export below gets its + // consumer in a later PR of the stack, which removes its entry. + "apps/web/src/components/sections-editor/deco-serve-connection.ts": [ + "exports" + ] } }