Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/canonical-post-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"sideshow": patch
---

Emit canonical `/p/<id>` post links everywhere a link is produced, finishing
the post/surface vocabulary migration. The CLI (`sideshow publish` / `update`
output), the viewer's copy-link and open-as-image actions, the `<link
rel="canonical">` / OpenGraph preview tags, the viewer's History API URL shapes
(`/p/:id` and `/session/:id/p/:postId`), and every MCP tool response —
including the deprecated `publish_surface` / `publish_snippet` aliases — now
return `/p/` URLs.

Nothing inbound changes: `/s/:id`, `/session/:id/s/:postId`, and `/s/:id.png`
remain accepted as legacy aliases (the screenshot Worker now matches both
`/p/:id.png` and `/s/:id.png`), and old `/s/` links keep resolving to the same
post page.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ agent and your browser live on different machines (or you want the viewer on you
phone). See **[docs/deploying.md](docs/deploying.md)**.

Each surface has an **open-as-image** action in its footer that renders the
surface to a PNG (`/s/:id.png`) — handy for pasting into a doc or a chat. The
surface to a PNG (`/p/:id.png`) — handy for pasting into a doc or a chat. The
image is captured by a headless browser, so it needs Cloudflare's [Browser
Rendering](https://developers.cloudflare.com/browser-rendering/) binding and
only works on a Workers deployment. On the local Node server there is no headless
Expand Down
2 changes: 1 addition & 1 deletion bin/sideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ function out(value) {
}

function outPost(post) {
out({ ...post, url: `${BASE}/s/${post.id}` });
out({ ...post, url: `${BASE}/p/${post.id}` });
}

const CONTENT_TYPES = {
Expand Down
32 changes: 26 additions & 6 deletions e2e/url-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ test("clicking a session updates the URL to /session/:id", async ({ page, server

// Selecting a session pushes /session/:id. The topmost surface auto-focuses
// internally, but the engine no longer pins it in the URL — only an explicit
// surface open (a deep link, or scrolling into one) writes /session/:id/s/:id.
// surface open (a deep link, or scrolling into one) writes /session/:id/p/:id.
await page.locator(`#sessionList .sess[data-id="${s2.sessionId}"]`).click();
await expect(page).toHaveURL(new RegExp(`/session/${s2.sessionId}$`));

Expand All @@ -22,7 +22,7 @@ test("auto-selecting a session on boot does not pin the default surface in the U
server,
}) => {
// A session with a post: landing at root auto-selects it. The topmost surface
// auto-focuses internally, but the URL must stay /session/:id — no /s/:id —
// auto-focuses internally, but the URL must stay /session/:id — no /p/:id —
// because the user didn't open a specific surface.
const s = await publish(server.url, { html: "<p>hi</p>", title: "Top", agent: "pi" });
await page.goto(server.url);
Expand Down Expand Up @@ -125,8 +125,9 @@ test("navigating to /session/:id/s/:surfaceId selects session and scrolls to sur
{ timeout: 6000 },
)
.toBe(true);
// URL should include the surface id
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}/s/${s3.id}`));
// URL should include the surface id (either the legacy inbound shape or the
// canonical /p/ shape the router writes)
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}/[sp]/${s3.id}`));
});

test("browser back/forward navigates between sessions", async ({ page, server }) => {
Expand Down Expand Up @@ -210,11 +211,11 @@ test("scrolling through surfaces updates the URL", async ({ page, server }) => {

// scroll the last surface into view
await page.locator(`.card[data-id="${s3.id}"]`).scrollIntoViewIfNeeded();
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}/s/${s3.id}$`));
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}/p/${s3.id}$`));

// scroll back to the first surface
await page.locator(`.card[data-id="${s1.id}"]`).scrollIntoViewIfNeeded();
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}/s/${s1.id}$`));
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}/p/${s1.id}$`));
});

test("/s/:id bare surface route shows the standalone full-page surface", async ({
Expand Down Expand Up @@ -243,3 +244,22 @@ test("/s/:id bare surface route shows the standalone full-page surface", async (
"Standalone",
);
});

test("/p/:id canonical post route shows the standalone full-page surface", async ({
page,
server,
}) => {
// The canonical link shape agents hand out (publish_post, the CLI, copy-link).
// Regression guard: in 0.11.0 the server resolved /p/:id but the viewer's
// router didn't parse it, so the link landed on the workspace instead of the post.
const s = await publish(server.url, { html: "<h2>Canonical</h2>", title: "Perma" });
await page.goto(`${server.url}/p/${s.id}`);

await expect(page.locator("#standalone")).toHaveCount(1);
await expect(page.locator("#sessionList")).toHaveCount(0);
await expect(page.locator(`.card[data-id="${s.id}"] .card-title`)).toHaveText("Perma");
await expect(page).toHaveURL(new RegExp(`/p/${s.id}$`));
await expect(page.frameLocator(`.card[data-id="${s.id}"] iframe`).locator("h2")).toHaveText(
"Canonical",
);
});
8 changes: 4 additions & 4 deletions mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ server.registerTool(
body: JSON.stringify({ title, surfaces: parts, session }),
}),
);
return text({ ...created, url: `${API}/s/${created.id}` });
return text({ ...created, url: `${API}/p/${created.id}` });
},
);

Expand All @@ -143,7 +143,7 @@ server.registerTool(
body: JSON.stringify({ surfaces: parts, title }),
}),
);
return text({ ...updated, url: `${API}/s/${updated.id}` });
return text({ ...updated, url: `${API}/p/${updated.id}` });
},
);

Expand All @@ -161,7 +161,7 @@ server.registerTool(
body: JSON.stringify({ title, surfaces: [{ kind: "html", html, kits }], session }),
}),
);
return text({ ...created, url: `${API}/s/${created.id}` });
return text({ ...created, url: `${API}/p/${created.id}` });
},
);

Expand All @@ -176,7 +176,7 @@ server.registerTool(
const updated = JSON.parse(
await api(`/api/posts/${id}`, { method: "PUT", body: JSON.stringify({ surfaces, title }) }),
);
return text({ ...updated, url: `${API}/s/${updated.id}` });
return text({ ...updated, url: `${API}/p/${updated.id}` });
},
);

Expand Down
4 changes: 2 additions & 2 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,8 +908,8 @@ export function createApp({
const postPreviewHead = (post: Post, request: Request) => {
const origin = new URL(request.url).origin;
const publicBasePath = requestBasePath(request);
const canonical = `${origin}${publicBasePath}/s/${post.id}`;
const image = `${origin}${publicBasePath}/s/${post.id}.png?card=1`;
const canonical = `${origin}${publicBasePath}/p/${post.id}`;
const image = `${origin}${publicBasePath}/p/${post.id}.png?card=1`;
const title = escapeHtml(post.title);
const description = "A https://sideshow.sh surface";
return [
Expand Down
26 changes: 11 additions & 15 deletions server/mcpHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,13 @@ export interface McpDeps {
export const coerceParts = coerceSurfaces;

export function registerMcp(app: Hono, deps: McpDeps) {
// The view URL's path segment: legacy tools emit /s/<id>; the new post tools
// emit the canonical /p/<id>. Both resolve to the same post page.
const postResult = (
result: { post: Post; userFeedback?: Feedback[] },
origin: string,
seg: "s" | "p" = "s",
) =>
// All tools emit the canonical /p/<id> view URL (the legacy /s/<id> route
// remains accepted inbound and resolves to the same post page).
const postResult = (result: { post: Post; userFeedback?: Feedback[] }, origin: string) =>
JSON.stringify(
{
...postWriteView(result.post),
url: `${origin}/${seg}/${result.post.id}`,
url: `${origin}/p/${result.post.id}`,
...(result.userFeedback && { userFeedback: result.userFeedback }),
},
null,
Expand Down Expand Up @@ -113,7 +109,7 @@ export function registerMcp(app: Hono, deps: McpDeps) {
agent: typeof args.agent === "string" ? args.agent : undefined,
});
if ("error" in result) throw new Error(result.error);
return postResult(result, origin, name === "publish_post" ? "p" : "s");
return postResult(result, origin);
}
case "update_post":
case "update_surface":
Expand All @@ -130,7 +126,7 @@ export function registerMcp(app: Hono, deps: McpDeps) {
}
const result = await deps.revisePost(String(args.id ?? ""), patch);
if ("error" in result) throw new Error(result.error);
return postResult(result, origin, name === "update_post" ? "p" : "s");
return postResult(result, origin);
}
case "wait_for_feedback": {
const result = await deps.waitForComments({
Expand Down Expand Up @@ -241,7 +237,7 @@ export function registerMcp(app: Hono, deps: McpDeps) {
agent: typeof args.agent === "string" ? args.agent : undefined,
});
if ("error" in result) throw new Error(result.error);
return postResult(result, origin, "p");
return postResult(result, origin);
}
case "add_surface": {
const surfaces = await coerceSurfaces([args.surface]);
Expand All @@ -251,7 +247,7 @@ export function registerMcp(app: Hono, deps: McpDeps) {
after: typeof args.after === "string" ? args.after : undefined,
});
if ("error" in result) throw new Error(result.error);
return postResult(result, origin, "p");
return postResult(result, origin);
}
case "edit_surface": {
let surface: Surface | undefined;
Expand All @@ -270,23 +266,23 @@ export function registerMcp(app: Hono, deps: McpDeps) {
},
);
if ("error" in result) throw new Error(result.error);
return postResult(result, origin, "p");
return postResult(result, origin);
}
case "remove_surface": {
const result = await deps.removePostSurface(
String(args.postId ?? ""),
String(args.target ?? ""),
);
if ("error" in result) throw new Error(result.error);
return postResult(result, origin, "p");
return postResult(result, origin);
}
case "reorder_surfaces": {
const result = await deps.reorderPostSurfaces(
String(args.postId ?? ""),
Array.isArray(args.order) ? args.order : [],
);
if ("error" in result) throw new Error(result.error);
return postResult(result, origin, "p");
return postResult(result, origin);
}
default:
throw new Error(`unknown tool: ${name}`);
Expand Down
12 changes: 6 additions & 6 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,8 @@ test("GET /s/:id emits absolute token-free canonical and preview image URLs", as
const surface = (await res.json()) as any;

const body = await (await app.request(`https://board.test/s/${surface.id}?key=secret`)).text();
const canonical = `https://board.test/s/${surface.id}`;
const image = `https://board.test/s/${surface.id}.png?card=1`;
const canonical = `https://board.test/p/${surface.id}`;
const image = `https://board.test/p/${surface.id}.png?card=1`;
assert.match(body, new RegExp(`<link rel="canonical" href="${canonical}">`));
assert.match(body, new RegExp(`<meta property="og:url" content="${canonical}">`));
assert.match(
Expand Down Expand Up @@ -367,12 +367,12 @@ test("GET /s/:id preview metadata respects configured base path", async () => {
const body = await (await app.request(`https://board.test/s/${surface.id}`)).text();
assert.match(
body,
new RegExp(`<link rel="canonical" href="https://board.test/u/alice/s/${surface.id}">`),
new RegExp(`<link rel="canonical" href="https://board.test/u/alice/p/${surface.id}">`),
);
assert.match(
body,
new RegExp(
`<meta property="og:image" content="https://board.test/u/alice/s/${surface.id}\\.png\\?card=1">`,
`<meta property="og:image" content="https://board.test/u/alice/p/${surface.id}\\.png\\?card=1">`,
),
);
assert.match(body, /window\.__SIDESHOW_BASE_PATH__="\/u\/alice"/);
Expand Down Expand Up @@ -1359,7 +1359,7 @@ test("mcp endpoint: initialize, tools/list, publish round trip", async () => {
const payload = JSON.parse(published.result.content[0].text);
assert.ok(payload.id);
assert.ok(payload.sessionId);
assert.ok(payload.url.includes(`/s/${payload.id}`));
assert.ok(payload.url.includes(`/p/${payload.id}`));

// session continuity: second publish into the returned session
const second = (await (
Expand Down Expand Up @@ -2557,7 +2557,7 @@ test("publish_surface MCP tool still accepts legacy parts", async () => {
)
).json()) as any;
const payload = JSON.parse(published.result.content[0].text);
assert.ok(payload.url.includes(`/s/${payload.id}`));
assert.ok(payload.url.includes(`/p/${payload.id}`));
const full = (await (await app.request(`/api/surfaces/${payload.id}`)).json()) as any;
assert.equal(full.surfaces[0].html, "<p>old</p>");
});
Expand Down
2 changes: 1 addition & 1 deletion test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ test("publish posts an html file and prints id + url + surface metadata", async
assert.equal(out.title, "Card");
assert.equal(out.sessionId, server.session.id);
assert.deepEqual(surfaceKinds(out), ["html"]);
assert.equal(out.url, `${server.url}/s/${out.id}`);
assert.equal(out.url, `${server.url}/p/${out.id}`);
assert.equal(out.version, 1);
} finally {
await server.close();
Expand Down
20 changes: 11 additions & 9 deletions test/workerScreenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,37 @@ import { test } from "node:test";
import { matchPostScreenshot, planPostScreenshot } from "../workers/screenshot.ts";

test("post screenshot route matches GET and HEAD requests without baking in an id alphabet", () => {
assert.equal(matchPostScreenshot("GET", "/s/4tgMLMav_WY.png"), "4tgMLMav_WY");
assert.equal(matchPostScreenshot("HEAD", "/s/future.id~v2.png"), "future.id~v2");
assert.equal(matchPostScreenshot("POST", "/s/abc123.png"), null);
assert.equal(matchPostScreenshot("HEAD", "/s/abc123"), null);
assert.equal(matchPostScreenshot("GET", "/s/nested/id.png"), null);
assert.equal(matchPostScreenshot("GET", "/p/4tgMLMav_WY.png"), "4tgMLMav_WY");
assert.equal(matchPostScreenshot("GET", "/s/4tgMLMav_WY.png"), "4tgMLMav_WY"); // legacy alias
assert.equal(matchPostScreenshot("HEAD", "/p/future.id~v2.png"), "future.id~v2");
assert.equal(matchPostScreenshot("POST", "/p/abc123.png"), null);
assert.equal(matchPostScreenshot("HEAD", "/p/abc123"), null);
assert.equal(matchPostScreenshot("GET", "/p/nested/id.png"), null);
assert.equal(matchPostScreenshot("GET", "/x/abc123.png"), null);
});

test("card screenshots use stable social-card dimensions without fullPage", () => {
const plan = planPostScreenshot(
new URL("https://workspace.test/s/abc123.png?card=1&w=640&theme=gruvbox&mode=dark&key=secret"),
new URL("https://workspace.test/p/abc123.png?card=1&w=640&theme=gruvbox&mode=dark&key=secret"),
"abc123",
"sideshow_mode=light",
);

assert.deepEqual(plan.viewport, { width: 1200, height: 630 });
assert.deepEqual(plan.screenshotOptions, { fullPage: false });
assert.equal(plan.target, "https://workspace.test/s/abc123?part=0&theme=gruvbox&mode=dark");
assert.equal(plan.target, "https://workspace.test/p/abc123?part=0&theme=gruvbox&mode=dark");
assert.doesNotMatch(plan.target, /key=secret|card=1|w=640/);
});

test("non-card screenshots preserve full-page behavior and configurable width", () => {
const plan = planPostScreenshot(
new URL("https://workspace.test/s/abc123.png?w=640&nocache=1"),
new URL("https://workspace.test/s/abc123.png?w=640&nocache=1"), // legacy inbound shape
"abc123",
"sideshow_mode=dark",
);

assert.deepEqual(plan.viewport, { width: 640, height: 800 });
assert.deepEqual(plan.screenshotOptions, { fullPage: true });
assert.equal(plan.target, "https://workspace.test/s/abc123?part=0&mode=dark");
assert.equal(plan.target, "https://workspace.test/p/abc123?part=0&mode=dark");
assert.equal(plan.noCache, true);
});
11 changes: 5 additions & 6 deletions viewer/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,18 @@ export function layoutMode(): "full" | "stream" {
return host().layout ?? (publicReadMode() === "session" ? "stream" : "full");
}

// `/s/:id` is the legacy wire alias for a post's permalink.
// `/p/:id` is a post's canonical permalink (`/s/:id` is the legacy alias).
export function postLink(id: string): string {
return `${location.origin}${appPath(`/s/${encodeURIComponent(id)}`)}`;
return `${location.origin}${appPath(`/p/${encodeURIComponent(id)}`)}`;
}

// The PNG screenshot of a post (the same /s/:id page, captured server-side).
// The PNG screenshot of a post (the same /p/:id page, captured server-side).
// Only reachable where `canScreenshot()` is true — see that helper.
// `/s/:id.png` is the legacy wire alias.
export function postImageLink(id: string): string {
return `${location.origin}${appPath(`/s/${encodeURIComponent(id)}.png`)}`;
return `${location.origin}${appPath(`/p/${encodeURIComponent(id)}.png`)}`;
}

// Whether the deployment can render post screenshots (the /s/:id.png route).
// Whether the deployment can render post screenshots (the /p/:id.png route).
// Host-first (cloud embed), falling back to the self-hosted global, mirroring
// isReadonly(). False on a plain Node server, which has no Browser Rendering.
export function canScreenshot(): boolean {
Expand Down
Loading
Loading