From 7ac1a98bbae1960e4b009ebf4fa40323964f1d66 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Sun, 5 Jul 2026 15:19:18 -0400 Subject: [PATCH] fix(links): emit canonical /p/ post links everywhere; teach the viewer router to parse them /p/ links were half-implemented in 0.11.0: the server resolved them but the viewer's History-API router only parsed /s/ shapes, so canonical links from publish_post landed on the workspace instead of the post. The router now parses both spellings and writes /p/; the CLI, viewer copy-link/open-as-image, OG/canonical meta tags, screenshot worker, and all MCP tool responses (including deprecated aliases) now emit /p/. Legacy /s/ URLs remain accepted inbound. Adds a /p/:id standalone e2e regression test. --- .changeset/canonical-post-links.md | 16 +++++++++++++++ README.md | 2 +- bin/sideshow.js | 2 +- e2e/url-routing.spec.ts | 32 ++++++++++++++++++++++++------ mcp/server.ts | 8 ++++---- server/app.ts | 4 ++-- server/mcpHttp.ts | 26 ++++++++++-------------- test/api.test.ts | 12 +++++------ test/cli.test.ts | 2 +- test/workerScreenshot.test.ts | 20 ++++++++++--------- viewer/src/api.ts | 11 +++++----- viewer/src/host.ts | 15 +++++++------- workers/index.ts | 6 +++--- workers/screenshot.ts | 7 ++++--- 14 files changed, 99 insertions(+), 64 deletions(-) create mode 100644 .changeset/canonical-post-links.md diff --git a/.changeset/canonical-post-links.md b/.changeset/canonical-post-links.md new file mode 100644 index 0000000..8a4bc8f --- /dev/null +++ b/.changeset/canonical-post-links.md @@ -0,0 +1,16 @@ +--- +"sideshow": patch +--- + +Emit canonical `/p/` 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 `` / 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. diff --git a/README.md b/README.md index 0efa448..1497314 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bin/sideshow.js b/bin/sideshow.js index b499ae3..55083da 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -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 = { diff --git a/e2e/url-routing.spec.ts b/e2e/url-routing.spec.ts index d81d159..32a5dff 100644 --- a/e2e/url-routing.spec.ts +++ b/e2e/url-routing.spec.ts @@ -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}$`)); @@ -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: "

hi

", title: "Top", agent: "pi" }); await page.goto(server.url); @@ -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 }) => { @@ -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 ({ @@ -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: "

Canonical

", 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", + ); +}); diff --git a/mcp/server.ts b/mcp/server.ts index e4de041..a3c91cd 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -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}` }); }, ); @@ -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}` }); }, ); @@ -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}` }); }, ); @@ -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}` }); }, ); diff --git a/server/app.ts b/server/app.ts index afbe3dc..a6351d0 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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 [ diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index 7b22a9b..758cb1b 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -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/; the new post tools - // emit the canonical /p/. 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/ view URL (the legacy /s/ 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, @@ -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": @@ -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({ @@ -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]); @@ -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; @@ -270,7 +266,7 @@ 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( @@ -278,7 +274,7 @@ export function registerMcp(app: Hono, deps: McpDeps) { 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( @@ -286,7 +282,7 @@ export function registerMcp(app: Hono, deps: McpDeps) { 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}`); diff --git a/test/api.test.ts b/test/api.test.ts index a5765ec..c408687 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -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(``)); assert.match(body, new RegExp(``)); assert.match( @@ -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(``), + new RegExp(``), ); assert.match( body, new RegExp( - ``, + ``, ), ); assert.match(body, /window\.__SIDESHOW_BASE_PATH__="\/u\/alice"/); @@ -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 ( @@ -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, "

old

"); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 506b02e..4e989e5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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(); diff --git a/test/workerScreenshot.test.ts b/test/workerScreenshot.test.ts index 52c4d0c..69c6848 100644 --- a/test/workerScreenshot.test.ts +++ b/test/workerScreenshot.test.ts @@ -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); }); diff --git a/viewer/src/api.ts b/viewer/src/api.ts index d18c63b..5d4268a 100644 --- a/viewer/src/api.ts +++ b/viewer/src/api.ts @@ -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 { diff --git a/viewer/src/host.ts b/viewer/src/host.ts index 047c6e5..f39f693 100644 --- a/viewer/src/host.ts +++ b/viewer/src/host.ts @@ -44,7 +44,7 @@ export interface SideshowHost { // Live-update transport. Self-hosted defaults to SSE; embedders can opt into // WebSocket when their host implements `/api/events` as a hibernatable socket. liveTransport?: LiveTransport; - // Whether this deployment can render a surface as a PNG (the /s/:id.png route). + // Whether this deployment can render a surface as a PNG (the /p/:id.png route). // That route is served only by a Cloudflare Worker with the Browser Rendering // binding; a plain Node server (local dev, `npm start`) has no way to drive a // headless browser, so it is absent there. The engine always shows a @@ -189,8 +189,9 @@ export function host(): SideshowHost { } // Self-hosted default host: base path from the hosted-wrapper global (set by any -// wrapper before the engine loads; empty at root), routing over the History API -// with URL shapes identical to before (/session/:id and /session/:id/s/:sid). +// wrapper before the engine loads; empty at root), routing over the History API. +// Writes canonical URL shapes (/session/:id and /session/:id/p/:pid) and still +// parses the legacy /s/ spellings on the way in. export function createDefaultHost(): SideshowHost { const basePath = window.__SIDESHOW_BASE_PATH__ ?? ""; const subs = new Set<(r: Route) => void>(); @@ -200,17 +201,17 @@ export function createDefaultHost(): SideshowHost { ? location.pathname.slice(basePath.length) : location.pathname; const qSurface = new URLSearchParams(location.search).get("surface") ?? undefined; - const m = rest.match(/^\/session\/([^/]+)(?:\/s\/([^/]+))?/); + const m = rest.match(/^\/session\/([^/]+)(?:\/[sp]\/([^/]+))?/); if (m) return { sessionId: m[1], surfaceId: m[2] ?? qSurface }; - const surfaceOnly = rest.match(/^\/s\/([^/]+)/); + const surfaceOnly = rest.match(/^\/[sp]\/([^/]+)/); if (surfaceOnly) return { surfaceId: surfaceOnly[1] }; return { surfaceId: qSurface }; }; const urlFor = (to: Route): string => { - if (!to.sessionId) return to.surfaceId ? `${basePath}/s/${to.surfaceId}` : basePath || "/"; + if (!to.sessionId) return to.surfaceId ? `${basePath}/p/${to.surfaceId}` : basePath || "/"; return to.surfaceId - ? `${basePath}/session/${to.sessionId}/s/${to.surfaceId}` + ? `${basePath}/session/${to.sessionId}/p/${to.surfaceId}` : `${basePath}/session/${to.sessionId}`; }; diff --git a/workers/index.ts b/workers/index.ts index 7ee4942..1db111e 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -34,7 +34,7 @@ export class SideshowBoard extends DurableObject { authToken: env.SIDESHOW_TOKEN, publicRead, // This Worker deploys with the Browser Rendering binding (wrangler.jsonc), - // so /s/:id.png is live — tell the viewer to enable the screenshot action. + // so /p/:id.png is live — tell the viewer to enable the screenshot action. screenshots: true, version: pkg.version, upgradeCommand: "git pull && npm run deploy", @@ -56,7 +56,7 @@ export default { } const workspace = env.BOARD.get(env.BOARD.idFromName("default")); - // Screenshot: GET /s/:id.png → PNG of the rendered post page. + // Screenshot: GET /p/:id.png (or legacy /s/:id.png) → PNG of the rendered post page. // Auth is decided by the app — we forward the user's credentials to the DO // and only proceed if it returns 200. const url = new URL(request.url); @@ -64,7 +64,7 @@ export default { if (!postId) return workspace.fetch(request); // Let the app decide auth: forward the request (with user cookies/headers) - // to the real /s/:id?part=0 renderer. We pass theme/mode so the rendered + // to the real /p/:id?part=0 renderer. We pass theme/mode so the rendered // page matches what the viewer shows; the width is configurable via ?w= // (default 800). Social card mode is fixed at 1200x630. const plan = planPostScreenshot(url, postId, request.headers.get("cookie")); diff --git a/workers/screenshot.ts b/workers/screenshot.ts index 30d62ad..3490ab1 100644 --- a/workers/screenshot.ts +++ b/workers/screenshot.ts @@ -14,8 +14,9 @@ export function matchPostScreenshot(method: string, pathname: string): string | // Keep this independent of the post id alphabet. The store owns id validity; // the Worker only recognizes the stable one-segment screenshot shape and // forwards the captured id to the app for the real existence/auth check. That - // way a future id alphabet change doesn't break link previews. - return pathname.match(/^\/s\/([^/]+)\.png$/)?.[1] ?? null; + // way a future id alphabet change doesn't break link previews. Both the + // canonical /p/:id.png and the legacy /s/:id.png shapes are recognized. + return pathname.match(/^\/[sp]\/([^/]+)\.png$/)?.[1] ?? null; } export function planPostScreenshot( @@ -36,7 +37,7 @@ export function planPostScreenshot( : (modeCookie as "light" | "dark" | undefined); const checkUrl = new URL(requestUrl); - checkUrl.pathname = `/s/${postId}`; + checkUrl.pathname = `/p/${postId}`; checkUrl.search = ""; // clear .png query params, including tokens checkUrl.searchParams.set("part", "0"); if (theme) checkUrl.searchParams.set("theme", theme);