diff --git a/web/src/components/community/PublishModal.test.tsx b/web/src/components/community/PublishModal.test.tsx index 1b413754e..63d37156a 100644 --- a/web/src/components/community/PublishModal.test.tsx +++ b/web/src/components/community/PublishModal.test.tsx @@ -6,6 +6,8 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildStoredPatternCode, communityPatternUrl } from "@/lib/community/license"; +import { CODE_MAX } from "@/lib/community/validate"; import PublishModal from "./PublishModal"; const push = vi.fn(); @@ -102,3 +104,90 @@ describe("PublishModal", () => { expect(server.mock).toHaveBeenCalledTimes(2); }); }); + +// The community stores a pattern inside its licence header and footer, and +// "Open in Pattern Lab" hands that stored text to the lab unchanged. The API +// takes the wrapping off before it measures; the modal measured the wrapped +// text, so a fork it would have accepted was refused before it was sent. +describe("PublishModal code size", () => { + /** A pattern body of exactly `length` characters. */ + function bodyOf(length: number) { + const pad = "\n// "; + return `${CODE}${pad}${"x".repeat(length - CODE.length - pad.length)}`; + } + + /** How a fork of `@ada`'s pattern comes back out of the community. */ + function storedFork(body: string) { + return buildStoredPatternCode(body, { + title: "Pentagrid Tower (remix)", + license: "CC-BY-SA-4.0", + handle: "grace", + date: "2026-10-01", + basedOn: { title: "Pentagrid Tower", handle: "ada", url: communityPatternUrl("p0") }, + }); + } + + function openFork(code: string) { + render( + {}} + />, + ); + return screen.getByRole("button", { name: "Publish to the wall" }); + } + + it("publishes an untouched fork whose body fits under the cap", async () => { + const code = storedFork(bodyOf(99_700)); + expect(code.length).toBeGreaterThan(CODE_MAX); + + const button = openFork(code); + act(() => button.click()); + + expect(screen.queryByText(/KB of code/)).toBeNull(); + expect(server.mock).toHaveBeenCalledTimes(1); + await server.answer(201, { id: "p2" }); + await waitFor(() => expect(push).toHaveBeenCalledWith("/community/p/p2")); + }); + + it("saves the author's own post reopened in the lab, unedited", () => { + const code = storedFork(bodyOf(99_700)); + render( + {}} + />, + ); + + act(() => screen.getByRole("button", { name: "Update this pattern" }).click()); + + expect(screen.queryByText(/KB of code/)).toBeNull(); + expect(server.mock).toHaveBeenCalledTimes(1); + expect(server.mock.mock.calls[0]).toEqual([ + "/api/community/patterns/p1", + expect.objectContaining({ method: "PATCH" }), + ]); + }); + + it("still stops a body over the cap before it is sent", () => { + const button = openFork(storedFork(bodyOf(CODE_MAX + 600))); + act(() => button.click()); + + expect(screen.getByText(/This pattern is 101 KB of code/)).toBeInTheDocument(); + expect(server.mock).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/community/PublishModal.tsx b/web/src/components/community/PublishModal.tsx index 3e8adf055..3bdc40152 100644 --- a/web/src/components/community/PublishModal.tsx +++ b/web/src/components/community/PublishModal.tsx @@ -24,6 +24,7 @@ import { LICENSE_OPTIONS, forkLicenseOptions, licenseById, + stripShareWrapping, } from "@/lib/pattern/share"; import { captureEvent } from "@/lib/posthogEvents"; import AuthModal from "./AuthModal"; @@ -149,11 +150,16 @@ export default function PublishModal({ const send = async (): Promise => { const trimmed = title.trim(); setError(null); - if (code.length > CODE_MAX) { + // Measured the way the API measures it, with the licence header and + // footer off. "Open in Pattern Lab" hands back the stored code with both + // still on, a few hundred characters the server never counts, and + // counting them here refused a pattern the server takes unchanged. + const codeLength = (code.length <= CODE_MAX * 2 ? stripShareWrapping(code) : code).length; + if (codeLength > CODE_MAX) { // The server says the same thing, but after a round trip that costs // one of five publishes a minute — and without the likely cause. setError( - `This pattern is ${Math.round(code.length / 1000)} KB of code; the community takes up to ${ + `This pattern is ${Math.round(codeLength / 1000)} KB of code; the community takes up to ${ CODE_MAX / 1000 } KB. Imported images as pixel layers are the usual reason — hide or delete one and share again.`, );