Public: anyone can read this; the crew can change it.

A Jupyter notebook (.ipynb) renders as its cells and outputs, nothing run #10638

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10283-a-jupyter-notebook-ipynb-renders-as-its → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/10283-a-jupyter-notebook-ipynb-renders-as-its was merged into main as af981ae.

Review

Sign in to review this change.
10 files changed +271 −3
docs/design/areas/render.md +4
@@ -18,6 +18,10 @@ The header shows the size (`1.2 MB · PDF`) through `Rendered.summary`. Both the
1818
1919 Backgrounds, 256-colour above 15 and truecolour are dropped; so are cursor moves and OSC. A log with no escape code keeps the plain source view.
2020
21 ## Notebook (.ipynb)
22
23 `src/web/render/notebook.ts` draws cells as rows inside `.nb.prose`, with space between them and no box. Classes: `.nb-cell` (one row), `.nb-img` (an output picture, `max-width: 100%; height: auto` so a wide plot fits a phone), `.nb-cap` (a plain caption, muted, before HTML or script output shown as text). A long `pre` scrolls inside its cell, never the page.
24
2125 ## Archive listing
2226
2327 A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the existing `.table` alone, with no `.card` around it (path, size, modified; `data-dense`), and a `tfoot` for the count and unpacked size. No new class: `.archive-view` is only a hook on the table and has no rule. How it works: `docs/features/archive-listing.md`. A list that cannot be read falls back to the Download card with the reason as its one sentence.
docs/features/notebook.md added +6
@@ -0,0 +1,6 @@
1 # Jupyter notebooks
2
3 A `.ipynb` file opens on the blob page as its cells and saved outputs (`src/web/render/notebook.ts`). Nothing is run.
4 Markdown cells go through the forge's Markdown renderer. A code cell is a `pre` headed `In [n]:` with its execution count. Output text (streams, plain text, errors with their colour codes removed) is escaped text. PNG and JPEG outputs are pictures, drawn only when the base64 is well-formed, and shrink to fit a phone. HTML and script outputs are shown as escaped text and never inserted or run. Other output kinds are skipped.
5 A notebook over 1 MB, one that is not valid JSON, or one with no `cells` list shows the ordinary source view with one line saying why. At most 2,000 cells, and 2,000 outputs per cell, are drawn. `?view=source`, Raw and Download show the bytes untouched.
6 The terminal (SSH) file view has no notebook reader: it shows the file as plain text, the JSON itself, escaped like any text file. Nothing in it is run there either. Reading cells in the terminal is left out. Classes live in `docs/design/areas/render.md`.
src/web/pages/repo.ts +5 −3
@@ -88,7 +88,7 @@ import { overSize } from "../render/archive.ts";
8888 import { csvJs, isCsvPath } from "../render/csv.ts";
8989 import { fontJs } from "../render/font.ts";
9090 import { hasTable } from "../render/sqlite.ts";
91 import { fileKind, HEAD_BYTES, RENDER_CAP } from "../render/kinds.ts";
91 import { fileKind, HEAD_BYTES, kindByName, RENDER_CAP } from "../render/kinds.ts";
9292 import { RENDERERS, rendererFor } from "../render/renderers.ts";
9393 import { downloadHref, mediaHref } from "./media.ts";
9494 import { browseBase, localScope, type RepoScope } from "../scope.ts";
@@ -722,7 +722,7 @@ export function blobBody(
722722 content: empty({
723723 icon: "alert",
724724 title: "Too large to render",
725 note: `${fileSize(blob.size)} — over the render cap. Download it instead.`,
725 note: why !== undefined ? `${fileSize(blob.size)} — ${why}` : `${fileSize(blob.size)} — over the render cap. Download it instead.`,
726726 action: btn("Download", {
727727 href: downloadHref(base, ref, path),
728728 variant: "primary",
@@ -751,7 +751,7 @@ export function blobBody(
751751 const fileHref = `${base}/blob/${encoded}/${path}`;
752752 return {
753753 lines: total,
754 content: html`<div class="card code">
754 content: html`${why !== undefined ? html`<p class="nb-cap">${why}</p>` : html``}<div class="card code">

Non-blocking: nb-cap is a notebook class, but it is used here for a generic file-view reason. Share it as a general caption, or name it for this use.

755755 <table>
756756 ${raw(rows)}
757757 </table>
@@ -1097,6 +1097,8 @@ function blobRenderer(scope: RepoScope, path: string, blob: Blob): ReturnType<ty
10971097
10981098 /** Why an archive too big to list is not listed, as one sentence. */
10991099 function overCap(path: string, blob: Blob): string | undefined {
1100 if (kindByName(path) === "notebook" && blob.size > RENDER_CAP.notebook)
1101 return `This notebook is over ${RENDER_CAP.notebook / 1_000_000} MB, so it is not drawn as cells${blob.kind === "too_large" ? ". Download it to read it" : " and is shown as plain text"}.`;
11001102 if (blob.kind !== "binary" || fileKind(path, blob.body.subarray(0, HEAD_BYTES)) !== "archive") return undefined;
11011103 return blob.size > RENDER_CAP.archive ? overSize(RENDER_CAP.archive) : undefined;
11021104 }
src/web/render/kinds.ts +3
@@ -25,6 +25,7 @@ export type FileKind =
2525 | "font"
2626 | "config"
2727 | "markdown"
28 | "notebook"
2829 | "patch"
2930 | "log"
3031 | "subtitles"
@@ -54,6 +55,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
5455 font: 20 * MB,
5556 config: 1 * MB,
5657 markdown: 1 * MB,
58 notebook: 1 * MB,
5759 patch: 1 * MB,
5860 log: 1 * MB,
5961 subtitles: 5 * MB,
@@ -159,6 +161,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
159161 md: "markdown",
160162 markdown: "markdown",
161163 mdx: "markdown",
164 ipynb: "notebook",
162165 patch: "patch",
163166 diff: "patch",
164167 log: "log",
src/web/render/notebook.ts added +88
@@ -0,0 +1,88 @@
1 /**
2 * A Jupyter notebook as its cells and saved outputs; nothing is run. Markdown
3 * cells go through the forge's own Markdown renderer. Everything else is
4 * escaped text, except PNG and JPEG outputs, which are `<img>` data URIs
5 * only after the base64 is checked. HTML and JavaScript outputs are shown
6 * as source and never inserted.
7 */
8
9 import { html, type Html } from "../html.ts";
10 import { renderMarkdown } from "../markdown.ts";
11 import type { Rendered, RenderInput } from "./renderers.ts";
12
13 type Json = Record<string, unknown>;
14 const isObj = (v: unknown): v is Json => typeof v === "object" && v !== null && !Array.isArray(v);
15
16 /** nbformat stores text as a string or an array of lines. */
17 const text = (v: unknown): string =>
18 typeof v === "string" ? v : Array.isArray(v) ? v.filter((x) => typeof x === "string").join("") : "";
19
20 const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
21 const IMAGES = ["image/png", "image/jpeg"] as const;
22 /** Outputs shown as source, with the plain words that name them. */
23 const SOURCES = [
24 ["text/html", "HTML output, shown as text"],
outdated

Unstyled image with no max-width: overflows a 360px phone. Needs max-width:100%; height:auto in the content styles.

25 ["application/javascript", "Script output, shown as text"],
26 ] as const;
27 /** The most outputs and cells drawn, so a hostile file cannot make a huge page. */
28 const MAX_CELLS = 2_000;
29
30 const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
31
32 function image(mime: string, data: unknown): Html | undefined {
33 const b64 = text(data).replace(/\s+/g, "");
34 if (!BASE64.test(b64)) return undefined;
35 return html`<img class="nb-img" alt="Notebook output" src="data:${mime};base64,${b64}" />`;
36 }
37
38 function output(out: unknown): Html {
39 if (!isObj(out)) return html``;
40 if (out.output_type === "stream")
41 return html`<pre class="nb-out">${text(out.text)}</pre>`;
42 if (out.output_type === "error") {
43 const trace = Array.isArray(out.traceback) ? text(out.traceback) : `${text(out.ename)}: ${text(out.evalue)}`;
44 return html`<pre class="nb-out nb-err">${stripAnsi(trace)}</pre>`;
45 }
46 const data = isObj(out.data) ? out.data : {};
outdated

Principle 8: raw mime type in a hand-rolled muted small paragraph. Use plain words, e.g. 'HTML output, shown as text'.

47 const parts: Html[] = [];
48 for (const mime of IMAGES) {
49 if (data[mime] === undefined) continue;
50 const img = image(mime, data[mime]);
51 if (img !== undefined) parts.push(img);
52 }
53 if (data["text/plain"] !== undefined) parts.push(html`<pre class="nb-out">${text(data["text/plain"])}</pre>`);
54 for (const [mime, label] of SOURCES)
55 if (data[mime] !== undefined)
56 parts.push(html`<div class="nb-cap">${label}</div><pre class="nb-out">${text(data[mime])}</pre>`);
57 return html`${parts}`;
58 }
59
60 function cell(c: unknown): Html {
61 if (!isObj(c)) return html``;
62 const source = text(c.source);
63 if (c.cell_type === "markdown") return html`<div class="nb-cell">${renderMarkdown(source)}</div>`;
64 if (c.cell_type === "code") {
65 const n = typeof c.execution_count === "number" && Number.isFinite(c.execution_count) ? String(c.execution_count) : " ";
66 const outs = Array.isArray(c.outputs) ? c.outputs.slice(0, MAX_CELLS).map(output) : [];
67 return html`<div class="nb-cell"><pre class="nb-in"><span class="muted">In [${n}]:</span> ${source}</pre>${outs}</div>`;
68 }
69 return html`<div class="nb-cell"><pre>${source}</pre></div>`;
70 }
71
72 export function notebook(input: RenderInput): Rendered {
73 const fail = (why: string): Rendered => ({ content: html``, lines: 0, fallback: why });
74 let doc: unknown;
75 try {
76 doc = JSON.parse(new TextDecoder().decode(input.body));
77 } catch {
78 return fail("This notebook is not valid JSON, so it is shown as plain text.");
79 }
80 if (!isObj(doc) || !Array.isArray(doc.cells))
81 return fail("This notebook has no cells list, so it is shown as plain text.");
82 const cells = doc.cells.slice(0, MAX_CELLS);
outdated

Principles 6 and 7: a card around the whole notebook is a box around a thing. Use rows with space between cells.

83 return {
84 lines: 0,
85 summary: `${doc.cells.length} cells`,
86 content: html`<div class="nb prose">${cells.map(cell)}</div>`,
87 };
88 }
src/web/render/renderers.ts +2
@@ -19,6 +19,7 @@ import { fileKind, HEAD_BYTES, kindByName, NO_BYTES, RENDER_CAP, type FileKind }
1919 import { log, hasEscape } from "./log.ts";
2020 import { markdown } from "./markdown.ts";
2121 import { model3d } from "./model3d.ts";
22 import { notebook } from "./notebook.ts";
2223 import { patch } from "./patch.ts";
2324 import { pdf } from "./pdf.ts";
2425 import { sqlite } from "./sqlite.ts";
@@ -62,6 +63,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
6263 image,
6364 svg: image,
6465 markdown,
66 notebook,
6567 pdf,
6668 csv,
6769 sqlite,
src/web/style/content.ts +6
@@ -414,6 +414,12 @@ form.inline { display: inline; margin-left: auto; }
414414 .csv-view .num { text-align: right; }
415415 .csv-more { padding: 10px 12px; margin: 0; }
416416
417 /* A notebook (render/notebook.ts): cells are rows with space between, never boxes. */
418 .nb-cell { margin: 0 0 28px; min-width: 0; }
419 .nb pre { max-width: 100%; overflow-x: auto; margin: 8px 0; }
420 .nb-img { display: block; max-width: 100%; height: auto; margin: 8px 0; }
421 .nb-cap { color: var(--muted-fg); font-size: 12.5px; margin-top: 8px; }
422
417423 /* A terminal log (render/log.ts): SGR colours as fixed classes, theme tokens only. */
418424 .ansi-red { color: var(--danger); }
419425 .ansi-green { color: var(--success); }
test/blob-notebook-page.test.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * A `.ipynb` committed to a repository, opened on its blob page over HTTP by
3 * an anonymous reader: the server-rendered route and its facts as rows.
4 */
5
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { seedAdmin } from "./helpers/admin.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 describe("a .ipynb on its blob page", () => {
19 let dataDir = "";
20 let app: App;
21 let servers: Servers;
22 let base = "";
23
24 beforeAll(async () => {
25 dataDir = await mkdtemp(join(tmpdir(), "forge-blob-ipynb-"));
26 app = createApp({ dataDir, port: 0 });
27 servers = await serve(app);
28 base = servers.url;
29 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-patch-passphrase" });
30 if (!a.ok) throw new Error("signup failed");
31 const created = await createRepo(app, a.value, { name: "p", visibility: "public" });
32 if (!created.ok) throw new Error("repo failed");
33 const small = JSON.stringify({
34 cells: [{ cell_type: "code", execution_count: 1, source: "1", outputs: [{ output_type: "execute_result", data: { "text/html": "<script>alert(1)</script>" } }] }],
35 });
36 const big = JSON.stringify({ cells: [{ cell_type: "markdown", source: "x".repeat(1_100_000) }] });
37 const made = await commitToBranch(app, a.value, created.value, {
38 message: "notebooks",
39 files: [
40 { path: "ok.ipynb", content: small },
41 { path: "big.ipynb", content: big },
42 { path: "bad.ipynb", content: "{not json" },
43 ],
44 });
45 if (!made.ok) throw new Error("commit failed");
46 }, 60_000);
47
48 afterAll(async () => {
49 await servers?.stop();
50 app?.close();
51 await rm(dataDir, { recursive: true, force: true });
52 });
53
54 test("a notebook is drawn as cells, its script output escaped", async () => {
55 const text = await (await fetch(`${base}/alice/p/blob/main/ok.ipynb`)).text();
56 expect(text).toContain("In [1]:");
57 expect(text).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
58 expect(text).not.toContain("<script>alert(1)");
59 });
60
61 test("over 1 MB, the page says why it is not drawn as cells", async () => {
62 const res = await fetch(`${base}/alice/p/blob/main/big.ipynb`);
63 const text = await res.text();
64 expect(res.status).toBe(200);
65 expect(text).toContain("This notebook is over 1 MB, so it is not drawn as cells");
66 expect(text).not.toContain("In [");
67 });
68
69 test("invalid JSON says why on the page", async () => {
70 const text = await (await fetch(`${base}/alice/p/blob/main/bad.ipynb`)).text();
71 expect(text).toContain("not valid JSON");
72 });
73 });
test/render-kinds.test.ts +1
@@ -60,6 +60,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
6060 zip: [of(0x50, 0x4b, 3, 4, 0), "archive", "application/octet-stream"],
6161 tar: [enc("x".repeat(257) + "ustar\0"), "archive", "application/octet-stream"],
6262 tgz: [of(0x1f, 0x8b, 8), "archive", "application/octet-stream"],
63 ipynb: [enc("{}"), "notebook", "application/octet-stream"],
6364 md: [enc("# a\n"), "markdown", "application/octet-stream"],
6465 markdown: [enc("# a\n"), "markdown", "application/octet-stream"],
6566 mdx: [enc("# a\n"), "markdown", "application/octet-stream"],
test/render-notebook.test.ts added +83
@@ -0,0 +1,83 @@
1 /** A notebook as cells and outputs: hostile HTML output stays inert text. */
2 import { describe, expect, test } from "bun:test";
3 import { notebook } from "../src/web/render/notebook.ts";
4 import { fileKind, RENDER_CAP } from "../src/web/render/kinds.ts";
5 import { RENDERERS, rendererFor } from "../src/web/render/renderers.ts";
6
7 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
8 const draw = (s: string) => notebook({ path: "a.ipynb", size: s.length, body: enc(s), media: "/m", download: "/d" });
9 const PNG = "iVBORw0KGgo=";
10 const NB = JSON.stringify({
11 cells: [
12 { cell_type: "markdown", source: ["# Title\n", "some *prose*"] },
13 {
14 cell_type: "code",
15 execution_count: 3,
16 source: "print('<b>x</b>')",
17 outputs: [
18 { output_type: "stream", name: "stdout", text: ["a < b\n"] },
19 { output_type: "display_data", data: { "image/png": PNG, "text/plain": "<Figure>" } },
20 { output_type: "execute_result", data: { "text/html": ["<script>alert(1)</script>"], "application/javascript": "alert(2)" } },
21 { output_type: "display_data", data: { "image/png": '"onerror="alert(3)' } },
22 ],
23 },
24 ],
25 });
26
27 describe("notebooks", () => {
28 test("the registry names .ipynb and a renderer draws it", () => {
29 expect(fileKind("x/a.ipynb", enc("{}"))).toBe("notebook");
30 expect(RENDERERS.notebook).toBe(notebook);
31 });
32
33 test("cells, outputs, and hostile HTML arrives escaped", () => {
34 const r = draw(NB);
35 const out = String(r.content);
36 expect(r.fallback).toBeUndefined();
37 expect(out).toContain("<h1");
38 expect(out).toContain("In [3]:");
39 expect(out).toContain("a &lt; b");
40 expect(out).toContain(`<img class="nb-img" alt="Notebook output" src="data:image/png;base64,${PNG}"`);
41 expect(out).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
42 expect(out).not.toContain("<script");
43 expect(out).toContain("&lt;b&gt;x&lt;/b&gt;");
44 expect(out).not.toContain("onerror");
45 expect(out.match(/<img/g)?.length).toBe(1);
46 });
47
48 test("invalid JSON, or no cells, falls back with a reason", () => {
49 expect(draw("{nope").fallback).toContain("not valid JSON");
50 expect(draw("[]").fallback).toContain("no cells");
51 });
52
53 test("a notebook over the cap is not handed to the renderer", () => {
54 const big = { kind: "text", size: RENDER_CAP.notebook + 1, body: enc("{}") } as never;
55 expect(rendererFor("a.ipynb", big)).toBeUndefined();
56 expect(rendererFor("a.ipynb", { kind: "text", size: 2, body: enc("{}") } as never)?.render).toBe(notebook);
57 });
58 });
59
60 describe("notebook edge cases", () => {
61 test("JPEG output is a picture and an error loses its colour codes", () => {
62 const out = String(
63 draw(
64 JSON.stringify({
65 cells: [
66 {
67 cell_type: "code",
68 source: "x",
69 outputs: [
70 { output_type: "display_data", data: { "image/jpeg": "/9j/4AAQ\n" } },
71 { output_type: "error", ename: "E", evalue: "v", traceback: ["\x1b[31mBoom\x1b[0m <b>"] },
72 ],
73 },
74 ],
75 }),
76 ).content,
77 );
78 expect(out).toContain('src="data:image/jpeg;base64,/9j/4AAQ"');
79 expect(out).toContain("Boom &lt;b&gt;");
80 expect(out).not.toContain("\x1b");
81 });
82
83 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs