From b8eed001efaa5fa166af6a50dbc261237f92b91c Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Thu, 25 Jun 2026 22:59:43 -0400 Subject: [PATCH] fix(api): PATCH /api/posts/:id preserves surface kind on update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sideshow update always treated content as HTML, clobbering the original surface kind (markdown → html, code → html, etc.). Add a PATCH endpoint that accepts raw content and slots it into the existing surface's kind, preserving extra fields (language, cols, layout, etc.). - PATCH /api/posts/:id: accepts { content, title, kits }, reads the existing post's first surface kind, maps content into the correct field, validates, and delegates to reviseSurface. Errors on multi-surface posts (400) and non-text kinds like image/trace (400). JSON surfaces parse the content string. HTML surfaces accept kits. - CLI update command: switches from PUT with hardcoded {kind:'html'} to PATCH with {content, title, kits}. - 18 new API tests covering every surface kind, field preservation, error cases, title-only updates, version history, and feedback. Closes the update-kind-bug where `sideshow update file.md` on a markdown post would render raw markdown as HTML. --- .changeset/update-preserves-surface-kind.md | 17 ++ bin/sideshow.js | 13 +- server/app.ts | 74 +++++ test/api.test.ts | 298 ++++++++++++++++++++ 4 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 .changeset/update-preserves-surface-kind.md diff --git a/.changeset/update-preserves-surface-kind.md b/.changeset/update-preserves-surface-kind.md new file mode 100644 index 0000000..86a890b --- /dev/null +++ b/.changeset/update-preserves-surface-kind.md @@ -0,0 +1,17 @@ +--- +"sideshow": patch +--- + +`sideshow update` now preserves the surface kind instead of always treating +content as HTML. A markdown post updated with `sideshow update file.md` +stays markdown; a code post stays code with its language preserved; diffs keep +their layout, terminals keep their cols — every kind-specific field is carried +forward. The same fix applies to all text-content surface kinds (html, +markdown, code, diff, terminal, mermaid, json). + +Implemented as a new `PATCH /api/posts/:id` endpoint that accepts raw +`content` (plus optional `title` and `kits`) and slots it into the existing +surface's kind, rather than requiring the caller to construct the full typed +surface object. Multi-surface posts return a 400 for now — surface-level +targeting is a future addition. The existing `PUT` full-replacement API is +unchanged. diff --git a/bin/sideshow.js b/bin/sideshow.js index 413feaf..fceb0e4 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -1074,13 +1074,16 @@ const commands = { }); const id = positionals[0]; if (!id) fail("usage: sideshow update "); - const part = { kind: "html", html: readContent(positionals[1]) }; + const body = { title: flags.title }; + if (positionals[1] !== undefined) { + body.content = readContent(positionals[1]); + } const kits = normalizeKits(flags.kit); - if (kits) part.kits = kits; + if (kits) body.kits = kits; outSurface( - await api(`/api/surfaces/${id}`, { - method: "PUT", - body: JSON.stringify({ parts: [part], title: flags.title }), + await api(`/api/posts/${id}`, { + method: "PATCH", + body: JSON.stringify(body), }), ); }, diff --git a/server/app.ts b/server/app.ts index b7fc9ba..5e97895 100644 --- a/server/app.ts +++ b/server/app.ts @@ -919,6 +919,80 @@ export function createApp({ app.put("/api/posts/:id", revise); // canonical alias app.put("/api/snippets/:id", revise); // legacy alias + // Content-only update: accepts raw content and slots it into the existing + // surface's kind, preserving extra fields (language, cols, layout, etc.). + // Only single-surface posts for now; multi-surface needs --surface N. + const CONTENT_FIELD: Record = { + html: "html", + markdown: "markdown", + mermaid: "mermaid", + diff: "patch", + terminal: "text", + code: "code", + json: "data", + }; + app.patch("/api/posts/:id", async (c: any) => { + const body = await c.req.json().catch(() => null); + if (!body) return c.json({ error: "invalid JSON body" }, 400); + const { content, title, kits } = body; + if (content === undefined && title === undefined) { + return c.json({ error: "provide content and/or title" }, 400); + } + const existing = await store.getPost(c.req.param("id")); + if (!existing) return c.json({ error: "post not found" }, 404); + // Build the updated surfaces array. + let parts: Surface[] | undefined; + if (content !== undefined) { + if (typeof content !== "string") { + return c.json({ error: '"content" must be a string' }, 400); + } + if (existing.surfaces.length > 1) { + return c.json( + { + error: + "content update not supported for multi-surface posts; use PUT with a full surfaces array", + }, + 400, + ); + } + const surface = existing.surfaces[0]; + const field = CONTENT_FIELD[surface.kind]; + if (!field) { + return c.json({ error: `content update not supported for ${surface.kind} surfaces` }, 400); + } + // For json surfaces, parse the content string into a value. + let value: unknown = content; + if (surface.kind === "json") { + try { + value = JSON.parse(content); + } catch { + return c.json({ error: "content is not valid JSON" }, 400); + } + } + // Clone the existing surface, replace the content field, optionally update kits. + const updated: Surface = + surface.kind === "html" + ? { + ...surface, + html: value as string, + ...(kits !== undefined && { kits: Array.isArray(kits) ? kits : undefined }), + } + : ({ ...surface, [field]: value } as Surface); + const parsed = await validateSurfaces([updated]); + if (!parsed.ok) return c.json({ error: parsed.error }, 400); + parts = parsed.parts; + } + const result = await reviseSurface(c.req.param("id"), { + parts, + title: typeof title === "string" ? title : undefined, + }); + if ("error" in result) return c.json({ error: result.error }, result.status); + return c.json({ + ...writeResult(result.surface), + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }); + }); + const remove = async (c: any) => { const surface = await store.getPost(c.req.param("id")); if (!surface) return c.json({ error: "surface not found" }, 404); diff --git a/test/api.test.ts b/test/api.test.ts index f474074..cef0f5d 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -2092,3 +2092,301 @@ test("publish_surface MCP tool still accepts legacy parts", async () => { const full = (await (await app.request(`/api/surfaces/${payload.id}`)).json()) as any; assert.equal(full.surfaces[0].html, "

old

"); }); + +// --------------------------------------------------------------------------- +// PATCH /api/posts/:id — content-only update (preserves surface kind) +// --------------------------------------------------------------------------- + +const patch = (body: unknown) => ({ + method: "PATCH" as const, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +test("PATCH /api/posts/:id updates markdown content preserving kind", async () => { + const app = makeApp(); + // publish a markdown post + const created = (await ( + await app.request( + "/api/posts", + json({ title: "MD", surfaces: [{ kind: "markdown", markdown: "# v1" }] }), + ) + ).json()) as any; + + // patch with new content + const res = await app.request(`/api/posts/${created.id}`, patch({ content: "# v2" })); + assert.equal(res.status, 200); + const updated = (await res.json()) as any; + assert.equal(updated.version, 2); + + // verify the surface kept its kind + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].kind, "markdown"); + assert.equal(full.surfaces[0].markdown, "# v2"); +}); + +test("PATCH /api/posts/:id updates html content", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "HTML", surfaces: [{ kind: "html", html: "

v1

" }] }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}`, patch({ content: "

v2

" })); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].kind, "html"); + assert.equal(full.surfaces[0].html, "

v2

"); +}); + +test("PATCH /api/posts/:id updates code content preserving language", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Code", + surfaces: [{ kind: "code", code: "const x = 1;", language: "typescript" }], + }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}`, patch({ content: "const y = 2;" })); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].kind, "code"); + assert.equal(full.surfaces[0].code, "const y = 2;"); + assert.equal(full.surfaces[0].language, "typescript"); +}); + +test("PATCH /api/posts/:id updates diff content preserving layout", async () => { + const app = makeApp(); + const diffPatch = "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new"; + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Diff", + surfaces: [{ kind: "diff", patch: diffPatch, layout: "split" }], + }), + ) + ).json()) as any; + + const diffPatch2 = "--- a/y\n+++ b/y\n@@ -1 +1 @@\n-a\n+b"; + const res = await app.request(`/api/posts/${created.id}`, patch({ content: diffPatch2 })); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].kind, "diff"); + assert.equal(full.surfaces[0].patch, diffPatch2); + assert.equal(full.surfaces[0].layout, "split"); +}); + +test("PATCH /api/posts/:id updates terminal content preserving cols", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Term", + surfaces: [{ kind: "terminal", text: "$ ls\nfoo", cols: 80 }], + }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}`, patch({ content: "$ ls\nbar" })); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].kind, "terminal"); + assert.equal(full.surfaces[0].text, "$ ls\nbar"); + assert.equal(full.surfaces[0].cols, 80); +}); + +test("PATCH /api/posts/:id updates mermaid content", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Diagram", surfaces: [{ kind: "mermaid", mermaid: "graph LR; A-->B" }] }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}`, patch({ content: "graph TD; X-->Y" })); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].kind, "mermaid"); + assert.equal(full.surfaces[0].mermaid, "graph TD; X-->Y"); +}); + +test("PATCH /api/posts/:id updates json content (parses string)", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Data", surfaces: [{ kind: "json", data: { a: 1 } }] }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}`, patch({ content: '{"b":2}' })); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].kind, "json"); + assert.deepEqual(full.surfaces[0].data, { b: 2 }); +}); + +test("PATCH /api/posts/:id with invalid JSON content for json surface returns 400", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Data", surfaces: [{ kind: "json", data: { a: 1 } }] }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}`, patch({ content: "not valid json" })); + assert.equal(res.status, 400); +}); + +test("PATCH /api/posts/:id updates title alongside content", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Old", surfaces: [{ kind: "markdown", markdown: "# old" }] }), + ) + ).json()) as any; + + const res = await app.request( + `/api/posts/${created.id}`, + patch({ content: "# new", title: "New" }), + ); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.title, "New"); + assert.equal(full.surfaces[0].markdown, "# new"); +}); + +test("PATCH /api/posts/:id title-only update (no content)", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Old", surfaces: [{ kind: "markdown", markdown: "# keep" }] }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}`, patch({ title: "Renamed" })); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.title, "Renamed"); + assert.equal(full.surfaces[0].markdown, "# keep"); +}); + +test("PATCH /api/posts/:id rejects multi-surface posts", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Multi", + surfaces: [ + { kind: "html", html: "

one

" }, + { kind: "markdown", markdown: "two" }, + ], + }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}`, patch({ content: "new stuff" })); + assert.equal(res.status, 400); + const body = (await res.json()) as any; + assert.ok(body.error); +}); + +test("PATCH /api/posts/:id rejects unsupported surface kinds (image, trace)", async () => { + const app = makeApp(); + // image surfaces can't be content-updated (they reference an asset) + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Img", surfaces: [{ kind: "image", assetId: "abc123" }] }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}`, patch({ content: "nope" })); + assert.equal(res.status, 400); +}); + +test("PATCH /api/posts/:id returns 404 for unknown id", async () => { + const app = makeApp(); + const res = await app.request("/api/posts/nonexistent", patch({ content: "hi" })); + assert.equal(res.status, 404); +}); + +test("PATCH /api/posts/:id with no content and no title returns 400", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "X", surfaces: [{ kind: "html", html: "

x

" }] }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}`, patch({})); + assert.equal(res.status, 400); +}); + +test("PATCH /api/posts/:id updates kits on html surface", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Kit", surfaces: [{ kind: "html", html: "

x

", kits: ["issues"] }] }), + ) + ).json()) as any; + + const res = await app.request( + `/api/posts/${created.id}`, + patch({ content: "

y

", kits: ["slides"] }), + ); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].html, "

y

"); + assert.deepEqual(full.surfaces[0].kits, ["slides"]); +}); + +test("PATCH /api/posts/:id returns userFeedback like PUT does", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "FB", surfaces: [{ kind: "html", html: "

x

" }] }), + ) + ).json()) as any; + // leave a user comment so there's feedback to collect + await app.request("/api/comments", json({ surface: created.id, text: "nice", author: "user" })); + + const res = await app.request(`/api/posts/${created.id}`, patch({ content: "

y

" })); + assert.equal(res.status, 200); + const body = (await res.json()) as any; + assert.ok(Array.isArray(body.userFeedback)); +}); + +test("PATCH /api/posts/:id bumps version and keeps history", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Hist", surfaces: [{ kind: "markdown", markdown: "# v1" }] }), + ) + ).json()) as any; + assert.equal(created.version, 1); + + await app.request(`/api/posts/${created.id}`, patch({ content: "# v2" })); + + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.version, 2); + assert.equal(full.history.length, 1); + assert.equal(full.history[0].surfaces[0].markdown, "# v1"); +});