Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/export-memoize-rejected-assets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Session export now fetches a missing or non-image asset at most once per export. A session referencing one bad asset from many image surfaces previously re-read it per reference — a full byte clone or blob `SELECT` each time — which an unauthenticated reader could retrigger on a `publicRead` workspace.
5 changes: 5 additions & 0 deletions .changeset/export-shares-card-css.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

The session export now renders its card column from the same CSS the live viewer uses (`server/cardChrome.ts`), instead of a hand-copied echo that would drift as the viewer's cards evolve.
5 changes: 5 additions & 0 deletions .changeset/session-html-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": minor
---

Add session HTML export. `GET /api/sessions/:id/export` and the new `sideshow export` command render a whole session into one self-contained, shareable HTML file styled like the viewer's card column. Every surface that becomes HTML is embedded as a sandboxed `srcdoc` iframe using the exact `/s/:id` renderers, so the isolation rule holds inside the saved file; image surfaces are inlined as data URIs (allowlisted raster types only, capped at 32 MB of image bytes per export — further images degrade to a note), and sessions over 4 MB of surface text are rejected with a 413. Supports `?theme=`/`?mode=` pinning and `?download=1` for an attachment download.
5 changes: 5 additions & 0 deletions .changeset/share-bridge-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

The session export and the live viewer now share one link/resize policy (`server/bridgePolicy.ts`) instead of each restating it, and the export reuses the render cache `/s/:id` already populated — so exporting a session you just viewed skips re-running syntax highlighting and diff rendering for every surface. A garbage bridge-reported height now floors to the minimum in the viewer instead of producing an invalid CSS length.
78 changes: 57 additions & 21 deletions bin/sideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ usage:
--surface is a deprecated alias)
--author <name> defaults to agent name
sideshow list [--session <id>|--all] list posts
sideshow export [--session <id>] [--out <file>] [--theme <id>] [--mode <m>]
export a session as one self-contained HTML file
(default: auto session, never created; stdout)
sideshow show <id> show a single post (surfaces, indexes, ids, version, history)
sideshow sessions list sessions
sideshow demo seed two example sessions to explore the viewer
Expand All @@ -155,23 +158,35 @@ function fail(msg) {
process.exit(1);
}

async function api(path, init = {}) {
// Raw fetch with the CLI's standard failure handling — unreachable server and
// non-2xx (JSON error body) both exit via fail(). Callers own the body read:
// api() parses JSON; export reads HTML text; uploads send raw bytes.
async function rawFetch(path, init = {}) {
let res;
try {
res = await fetch(`${BASE}${path}`, {
...init,
headers: {
"content-type": "application/json",
...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
...init.headers,
},
});
} catch {
fail(`server not reachable at ${BASE} — start it with: sideshow serve`);
}
const body = await res.json().catch(() => ({}));
if (!res.ok) fail(body.error ?? `${res.status} ${res.statusText}`);
return body;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
fail(body.error ?? `${res.status} ${res.statusText}`);
}
return res;
}

async function api(path, init = {}) {
const res = await rawFetch(path, {
...init,
headers: { "content-type": "application/json", ...init.headers },
});
return res.json().catch(() => ({}));
}

// Like api(), but throws instead of exiting the process — for callers that must
Expand Down Expand Up @@ -452,22 +467,12 @@ async function uploadFile(file, { session, kind } = {}) {
params.set("filename", file.split(/[\\/]/).pop() ?? "upload");
if (session) params.set("session", session);
if (kind) params.set("kind", kind);
let res;
try {
res = await fetch(`${BASE}/api/assets?${params}`, {
method: "POST",
headers: {
"content-type": contentTypeFor(file),
...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
},
body: bytes,
});
} catch {
fail(`server not reachable at ${BASE} — start it with: sideshow serve`);
}
const body = await res.json().catch(() => ({}));
if (!res.ok) fail(body.error ?? `${res.status} ${res.statusText}`);
return body;
const res = await rawFetch(`/api/assets?${params}`, {
method: "POST",
headers: { "content-type": contentTypeFor(file) },
body: bytes,
});
return res.json().catch(() => ({}));
}

// Normalize repeated/comma-joined --kit flags into a deduped id list (or
Expand Down Expand Up @@ -1541,6 +1546,37 @@ const commands = {
out(await api(`/api/posts/${id}`));
},

// Export a whole session as one self-contained HTML file (every surface
// embedded as a sandboxed srcdoc iframe). resolveSession WITHOUT create — an
// export must never mint a session — and rawFetch (not api(), which
// JSON-parses) since the body is HTML.
async export() {
const { values: flags } = parse({
options: {
session: { type: "string" },
out: { type: "string" },
theme: { type: "string" },
mode: { type: "string" },
},
});
const session = await resolveSession(flags);
if (!session) {
fail("no session to export — pass --session <id> (export never creates a session)");
}
const q = new URLSearchParams();
if (flags.theme) q.set("theme", flags.theme);
if (flags.mode) q.set("mode", flags.mode);
const qs = q.toString();
const res = await rawFetch(`/api/sessions/${session}/export${qs ? `?${qs}` : ""}`);
const html = await res.text();
if (flags.out) {
writeFileSync(flags.out, html);
console.log(`Wrote ${flags.out} (${html.length} bytes)`);
} else {
process.stdout.write(html);
}
},

async sessions() {
parse();
out(await api("/api/sessions"));
Expand Down
Loading
Loading