@marimo-team/islands 0.23.16-dev23 → 0.23.16-dev24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
9
9
  import { ut as outputIsStale } from "./html-to-image-27MFfyOf.js";
10
10
  import "./chunk-5FQGJX7Z-B9WoXK1R.js";
11
11
  import { u as createLucideIcon } from "./dist-DIlxLFXG.js";
12
- import { a as DEFAULT_DECK_VERTICAL_ALIGN, c as SlideSidebar, cn as EyeOff, ct as Panel, fn as Code, i as DEFAULT_DECK_TRANSITION, l as Slide, ln as Expand, lt as PanelGroup, o as DEFAULT_SLIDE_TYPE, t as useNotebookCodeAvailable, ut as PanelResizeHandle } from "./code-visibility-Bi6qrSJV.js";
12
+ import { a as DEFAULT_DECK_VERTICAL_ALIGN, c as SlideSidebar, dt as PanelResizeHandle, i as DEFAULT_DECK_TRANSITION, l as Slide, ln as EyeOff, lt as Panel, o as DEFAULT_SLIDE_TYPE, pn as Code, t as useNotebookCodeAvailable, un as Expand, ut as PanelGroup } from "./code-visibility-DsEGA9_b.js";
13
13
  import { R as ChevronDown } from "./strings-DIy9NAmg.js";
14
14
  import { X as useDebouncedCallback } from "./input-C5K4VBvB.js";
15
15
  import { v as kioskModeAtom } from "./toDate-CqhBWjvO.js";
@@ -7328,10 +7328,10 @@ function resolveSlideContentStyle(e5) {
7328
7328
  var SubslideView = (e5) => {
7329
7329
  let t = (0, import_compiler_runtime.c)(24), { subslide: n, resolveShowCode: r, isEditable: i, slideConfigs: a, contentStyle: o } = e5, s, l, u, f, go, _o;
7330
7330
  if (t[0] !== o || t[1] !== i || t[2] !== r || t[3] !== a || t[4] !== n) {
7331
- let { slideLevel: e6, cumulativeByBlock: p2 } = buildSubslideNotes(n, a);
7331
+ let { slideLevel: e6, cumulativeByBlock: vo2 } = buildSubslideNotes(n, a);
7332
7332
  u = e6;
7333
- let vo2 = n.blocks.some((e7) => e7.cells.some((e8) => r(e8.id)));
7334
- l = w, s = SlideScrollContainer, f = vo2 ? "mo-slide-content flex flex-col gap-3" : "mo-slide-content", go = o, _o = n.blocks.map((e7, t2) => {
7333
+ let yo2 = n.blocks.some((e7) => e7.cells.some((e8) => r(e8.id)));
7334
+ l = w, s = SlideScrollContainer, f = yo2 ? "mo-slide-content flex flex-col gap-3" : "mo-slide-content", go = o, _o = n.blocks.map((e7, t2) => {
7335
7335
  let n2 = e7.cells.map((e8) => r(e8.id) ? i ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SlideCellView, { cell: e8 }, e8.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SlideCellReadOnlyView, { cell: e8 }, e8.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Slide, {
7336
7336
  cellId: e8.id,
7337
7337
  status: e8.status,
@@ -7339,7 +7339,7 @@ var SubslideView = (e5) => {
7339
7339
  stale: outputIsStale(e8, false)
7340
7340
  }, e8.id));
7341
7341
  if (e7.isFragment) {
7342
- let e8 = p2.get(t2);
7342
+ let e8 = vo2.get(t2);
7343
7343
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Bt, {
7344
7344
  as: "div",
7345
7345
  children: [n2, e8 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NotesAside, { text: e8 })]
@@ -7348,18 +7348,18 @@ var SubslideView = (e5) => {
7348
7348
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react.Fragment, { children: n2 }, t2);
7349
7349
  }), t[0] = o, t[1] = i, t[2] = r, t[3] = a, t[4] = n, t[5] = s, t[6] = l, t[7] = u, t[8] = f, t[9] = go, t[10] = _o;
7350
7350
  } else s = t[5], l = t[6], u = t[7], f = t[8], go = t[9], _o = t[10];
7351
- let p;
7352
- t[11] !== f || t[12] !== go || t[13] !== _o ? (p = /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
7351
+ let vo;
7352
+ t[11] !== f || t[12] !== go || t[13] !== _o ? (vo = /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
7353
7353
  className: f,
7354
7354
  style: go,
7355
7355
  children: _o
7356
- }), t[11] = f, t[12] = go, t[13] = _o, t[14] = p) : p = t[14];
7357
- let vo;
7358
- t[15] !== s || t[16] !== p ? (vo = /* @__PURE__ */ (0, import_jsx_runtime.jsx)(s, { children: p }), t[15] = s, t[16] = p, t[17] = vo) : vo = t[17];
7356
+ }), t[11] = f, t[12] = go, t[13] = _o, t[14] = vo) : vo = t[14];
7357
+ let yo;
7358
+ t[15] !== s || t[16] !== vo ? (yo = /* @__PURE__ */ (0, import_jsx_runtime.jsx)(s, { children: vo }), t[15] = s, t[16] = vo, t[17] = yo) : yo = t[17];
7359
7359
  let bo;
7360
7360
  t[18] === u ? bo = t[19] : (bo = u && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NotesAside, { text: u }), t[18] = u, t[19] = bo);
7361
7361
  let xo;
7362
- return t[20] !== l || t[21] !== vo || t[22] !== bo ? (xo = /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(l, { children: [vo, bo] }), t[20] = l, t[21] = vo, t[22] = bo, t[23] = xo) : xo = t[23], xo;
7362
+ return t[20] !== l || t[21] !== yo || t[22] !== bo ? (xo = /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(l, { children: [yo, bo] }), t[20] = l, t[21] = yo, t[22] = bo, t[23] = xo) : xo = t[23], xo;
7363
7363
  };
7364
7364
  function parkedRendersSource(e5) {
7365
7365
  let { isNoOutputPreview: t, isEditable: n, showCode: r } = e5;
@@ -7384,7 +7384,7 @@ var ParkedPreviewContent = (e5) => {
7384
7384
  output: l,
7385
7385
  stale: u
7386
7386
  }), t[5] = n.id, t[6] = n.output, t[7] = n.status, t[8] = u, t[9] = f) : f = t[9], f;
7387
- }, reveal_component_default = ({ slideCells: e5, layout: o, setLayout: s, noOutputIds: c, activeIndex: l, onSlideChange: u, mode: vo, configWidth: yo, isEditable: So = false }) => {
7387
+ }, reveal_component_default = ({ slideCells: e5, layout: o, setLayout: s, noOutputIds: c, activeIndex: l, onSlideChange: u, mode: _o, configWidth: p, isEditable: bo = false }) => {
7388
7388
  var _a2, _b2, _c;
7389
7389
  let To = (0, import_react.useRef)(null), Eo = (0, import_react.useRef)(null), { width: Oo, height: Po } = useSlideDimensions(To), Fo = useFullScreenElement() != null, Lo = useAtomValue(kioskModeAtom), Ro = (0, import_react.useMemo)(() => Lo ? [] : [ce], [Lo]), [zo, Bo] = (0, import_react.useState)(() => /* @__PURE__ */ new Set()), Vo = useNotebookCodeAvailable(e5), Ho = !isIslands() && Vo, Uo = l == null ? void 0 : e5[l], Wo = Uo ?? e5.at(0), { parkedPreviewCell: Go, isHeldEdit: Ko, isNoOutputPreview: qo, heldEditCellId: Jo, heldShowsCode: Yo, toggleHeldShowsCode: Xo } = useParkedPreview({
7390
7390
  activeCell: Uo,
@@ -7488,7 +7488,7 @@ var ParkedPreviewContent = (e5) => {
7488
7488
  useEventListener(document, "keydown", ms, { capture: true });
7489
7489
  let gs = Ko ? null : qo ? "Hidden as there is no output" : "Skipped in presentation", _s = Ko ? Yo : Zo(Go == null ? void 0 : Go.id), vs = parkedRendersSource({
7490
7490
  isNoOutputPreview: qo,
7491
- isEditable: So,
7491
+ isEditable: bo,
7492
7492
  showCode: _s
7493
7493
  }), ys = /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
7494
7494
  ref: To,
@@ -7512,13 +7512,13 @@ var ParkedPreviewContent = (e5) => {
7512
7512
  children: rs.stacks.map((e6, t) => e6.subslides.length === 1 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SubslideView, {
7513
7513
  subslide: e6.subslides[0],
7514
7514
  resolveShowCode: Zo,
7515
- isEditable: So,
7515
+ isEditable: bo,
7516
7516
  slideConfigs: o.cells,
7517
7517
  contentStyle: ss
7518
7518
  }, t) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ne, { children: e6.subslides.map((e7, t2) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SubslideView, {
7519
7519
  subslide: e7,
7520
7520
  resolveShowCode: Zo,
7521
- isEditable: So,
7521
+ isEditable: bo,
7522
7522
  slideConfigs: o.cells,
7523
7523
  contentStyle: ss
7524
7524
  }, t2)) }, t))
@@ -7537,7 +7537,7 @@ var ParkedPreviewContent = (e5) => {
7537
7537
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ParkedPreviewContent, {
7538
7538
  cell: Go,
7539
7539
  isNoOutputPreview: qo,
7540
- isEditable: So,
7540
+ isEditable: bo,
7541
7541
  showCode: _s
7542
7542
  })
7543
7543
  })
@@ -7579,7 +7579,7 @@ var ParkedPreviewContent = (e5) => {
7579
7579
  ]
7580
7580
  })
7581
7581
  });
7582
- return vo === "read" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
7582
+ return _o === "read" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
7583
7583
  className: "flex-1 min-w-0 flex flex-row gap-3",
7584
7584
  children: ys
7585
7585
  }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -7615,7 +7615,7 @@ var ParkedPreviewContent = (e5) => {
7615
7615
  })
7616
7616
  ]
7617
7617
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SlideSidebar, {
7618
- configWidth: yo,
7618
+ configWidth: p,
7619
7619
  layout: o,
7620
7620
  setLayout: s,
7621
7621
  activeConfigCell: Wo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.16-dev23",
3
+ "version": "0.23.16-dev24",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -64,10 +64,26 @@ export const MockRequestClient = {
64
64
  getWorkspaceFiles: vi.fn().mockResolvedValue({ files: [] }),
65
65
  getRunningNotebooks: vi.fn().mockResolvedValue({ files: [] }),
66
66
  shutdownSession: vi.fn().mockResolvedValue({}),
67
- exportAsHTML: vi.fn().mockResolvedValue({ html: "" }),
68
- exportAsIPYNB: vi.fn().mockResolvedValue(""),
69
- exportAsMarkdown: vi.fn().mockResolvedValue({ markdown: "" }),
70
- exportAsPDF: vi.fn().mockResolvedValue(new Blob()),
67
+ exportAsHTML: vi.fn().mockResolvedValue({
68
+ contents: "",
69
+ filename: "notebook.html",
70
+ mediaType: "text/html; charset=utf-8",
71
+ }),
72
+ exportAsIPYNB: vi.fn().mockResolvedValue({
73
+ contents: "",
74
+ filename: "notebook.ipynb",
75
+ mediaType: "text/plain; charset=utf-8",
76
+ }),
77
+ exportAsMarkdown: vi.fn().mockResolvedValue({
78
+ contents: "",
79
+ filename: "notebook.md",
80
+ mediaType: "text/plain; charset=utf-8",
81
+ }),
82
+ exportAsPDF: vi.fn().mockResolvedValue({
83
+ contents: new Blob(),
84
+ filename: "notebook.pdf",
85
+ mediaType: "application/pdf",
86
+ }),
71
87
  autoExportAsHTML: vi.fn().mockResolvedValue({}),
72
88
  autoExportAsMarkdown: vi.fn().mockResolvedValue({}),
73
89
  autoExportAsIPYNB: vi.fn().mockResolvedValue({}),
@@ -8,13 +8,11 @@ describe("runServerSidePDFDownload", () => {
8
8
  const downloadPDF = vi.fn().mockResolvedValue(undefined);
9
9
 
10
10
  await runServerSidePDFDownload({
11
- filename: "slides.py",
12
11
  preset: "document",
13
12
  downloadPDF,
14
13
  });
15
14
 
16
15
  expect(downloadPDF).toHaveBeenCalledWith({
17
- filename: "slides.py",
18
16
  webpdf: false,
19
17
  preset: "document",
20
18
  includeInputs: true,
@@ -26,13 +24,11 @@ describe("runServerSidePDFDownload", () => {
26
24
  const downloadPDF = vi.fn().mockResolvedValue(undefined);
27
25
 
28
26
  await runServerSidePDFDownload({
29
- filename: "slides.py",
30
27
  preset: "slides",
31
28
  downloadPDF,
32
29
  });
33
30
 
34
31
  expect(downloadPDF).toHaveBeenCalledWith({
35
- filename: "slides.py",
36
32
  webpdf: false,
37
33
  preset: "slides",
38
34
  includeInputs: true,
@@ -2,7 +2,6 @@
2
2
 
3
3
  type Preset = "document" | "slides";
4
4
  type DownloadPDF = (opts: {
5
- filename: string;
6
5
  webpdf: boolean;
7
6
  preset: Preset;
8
7
  includeInputs: boolean;
@@ -10,14 +9,12 @@ type DownloadPDF = (opts: {
10
9
  }) => Promise<void>;
11
10
 
12
11
  export async function runServerSidePDFDownload(opts: {
13
- filename: string;
14
12
  preset: Preset;
15
13
  downloadPDF: DownloadPDF;
16
14
  }): Promise<void> {
17
- const { filename, preset, downloadPDF } = opts;
15
+ const { preset, downloadPDF } = opts;
18
16
 
19
17
  await downloadPDF({
20
- filename,
21
18
  webpdf: false,
22
19
  preset,
23
20
  includeInputs: true,
@@ -83,6 +83,7 @@ import {
83
83
  ADD_PRINTING_CLASS,
84
84
  downloadAsPDF,
85
85
  downloadBlob,
86
+ downloadExportedFile,
86
87
  downloadHTMLAsImage,
87
88
  withLoadingToast,
88
89
  } from "@/utils/download";
@@ -197,7 +198,6 @@ export function useNotebookActions() {
197
198
  updateCellOutputs,
198
199
  });
199
200
  await runServerSidePDFDownload({
200
- filename,
201
201
  preset,
202
202
  downloadPDF: downloadAsPDF,
203
203
  });
@@ -231,11 +231,8 @@ export function useNotebookActions() {
231
231
  takeScreenshots: () => takeScreenshots({ progress }),
232
232
  updateCellOutputs,
233
233
  });
234
- const ipynb = await exportAsIPYNB({ download: false });
235
- downloadBlob(
236
- new Blob([ipynb], { type: "application/x-ipynb+json" }),
237
- Filenames.toIPYNB(document.title),
238
- );
234
+ const exportedFile = await exportAsIPYNB({ download: false });
235
+ downloadExportedFile(exportedFile);
239
236
  };
240
237
 
241
238
  await withLoadingToast("Downloading IPYNB...", runDownload);
@@ -255,7 +252,7 @@ export function useNotebookActions() {
255
252
  toastNotebookMustBeNamed();
256
253
  return;
257
254
  }
258
- await downloadAsHTML({ filename, includeCode: true });
255
+ await downloadAsHTML({ includeCode: true });
259
256
  },
260
257
  },
261
258
  {
@@ -266,7 +263,7 @@ export function useNotebookActions() {
266
263
  toastNotebookMustBeNamed();
267
264
  return;
268
265
  }
269
- await downloadAsHTML({ filename, includeCode: false });
266
+ await downloadAsHTML({ includeCode: false });
270
267
  },
271
268
  },
272
269
  {
@@ -275,11 +272,8 @@ export function useNotebookActions() {
275
272
  ),
276
273
  label: "Download as Markdown",
277
274
  handle: async () => {
278
- const md = await exportAsMarkdown({ download: false });
279
- downloadBlob(
280
- new Blob([md], { type: "text/plain" }),
281
- Filenames.toMarkdown(document.title),
282
- );
275
+ const exportedFile = await exportAsMarkdown({ download: false });
276
+ downloadExportedFile(exportedFile);
283
277
  },
284
278
  },
285
279
  {
@@ -202,7 +202,7 @@ const ActionButtons: React.FC<{
202
202
  if (!app) {
203
203
  return;
204
204
  }
205
- await downloadAsHTML({ filename: document.title, includeCode: true });
205
+ await downloadAsHTML({ includeCode: true });
206
206
  };
207
207
 
208
208
  const handleDownloadAsPython = async () => {
@@ -40,7 +40,7 @@ export const ShareStaticNotebookModal: React.FC<{
40
40
  e.preventDefault();
41
41
 
42
42
  onClose();
43
- const html = await exportAsHTML({
43
+ const { contents: html } = await exportAsHTML({
44
44
  download: false,
45
45
  includeCode: true,
46
46
  files: VirtualFileTracker.INSTANCE.filenames(),
@@ -94,4 +94,46 @@ describe("API", () => {
94
94
  }),
95
95
  ).rejects.toBeInstanceOf(HTTPError);
96
96
  });
97
+
98
+ it("preserves export metadata from response headers", async () => {
99
+ const response = new Response("markdown", {
100
+ headers: {
101
+ "Content-Disposition":
102
+ "attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.qmd",
103
+ "Content-Type": "text/plain; charset=utf-8",
104
+ },
105
+ });
106
+
107
+ await expect(
108
+ API.handleExportResponse({ data: "markdown", response }),
109
+ ).resolves.toEqual({
110
+ contents: "markdown",
111
+ filename: "résumé.qmd",
112
+ mediaType: "text/plain; charset=utf-8",
113
+ });
114
+ });
115
+
116
+ it("rejects export responses without a filename", async () => {
117
+ await expect(
118
+ API.handleExportResponse({
119
+ data: "markdown",
120
+ response: new Response(null, {
121
+ headers: { "Content-Type": "text/plain" },
122
+ }),
123
+ }),
124
+ ).rejects.toThrow("Export response is missing a filename");
125
+ });
126
+
127
+ it("rejects export responses without a media type", async () => {
128
+ await expect(
129
+ API.handleExportResponse({
130
+ data: "markdown",
131
+ response: new Response(null, {
132
+ headers: {
133
+ "Content-Disposition": "attachment; filename*=UTF-8''notebook.md",
134
+ },
135
+ }),
136
+ }),
137
+ ).rejects.toThrow("Export response is missing a media type");
138
+ });
97
139
  });
@@ -22,6 +22,7 @@ vi.mock("../api", async () => {
22
22
  return {
23
23
  ...actual,
24
24
  API: {
25
+ handleExportResponse: vi.fn((response) => response.data),
25
26
  handleResponse: vi.fn((response) => response.data),
26
27
  handleResponseReturnNull: vi.fn(() => null),
27
28
  },
@@ -6,6 +6,7 @@ import { Logger } from "../../utils/Logger";
6
6
  import { Strings } from "../../utils/strings";
7
7
  import { getRuntimeManager } from "../runtime/config";
8
8
  import type { RuntimeManager } from "../runtime/runtime";
9
+ import type { ExportedFile } from "./types";
9
10
 
10
11
  function getBaseUriWithoutQueryParams(): string {
11
12
  // Remove query params and hash
@@ -15,6 +16,15 @@ function getBaseUriWithoutQueryParams(): string {
15
16
  return url.toString();
16
17
  }
17
18
 
19
+ function getExportFilename(response: Response): string {
20
+ const contentDisposition = response.headers.get("Content-Disposition");
21
+ const match = contentDisposition?.match(/filename\*=UTF-8''([^;]+)/i);
22
+ if (!match) {
23
+ throw new Error("Export response is missing a filename");
24
+ }
25
+ return decodeURIComponent(match[1]);
26
+ }
27
+
18
28
  /**
19
29
  * Wrapper around fetch that adds XSRF token and session ID to the request and
20
30
  * strong types.
@@ -119,6 +129,23 @@ export const API = {
119
129
  }
120
130
  return Promise.resolve(response.data as T);
121
131
  },
132
+ handleExportResponse: async <T extends BlobPart>(response: {
133
+ data?: T | undefined;
134
+ error?: Record<string, unknown>;
135
+ response: Response;
136
+ }): Promise<ExportedFile<T>> => {
137
+ const contents = await API.handleResponse<T>(response);
138
+ const mediaType = response.response.headers.get("Content-Type");
139
+ if (!mediaType) {
140
+ throw new Error("Export response is missing a media type");
141
+ }
142
+
143
+ return {
144
+ contents,
145
+ filename: getExportFilename(response.response),
146
+ mediaType,
147
+ };
148
+ },
122
149
  handleResponseReturnNull: (response: {
123
150
  error?: Record<string, unknown>;
124
151
  response: Response;
@@ -22,7 +22,7 @@ function multipartInit(formData: FormData) {
22
22
  };
23
23
  }
24
24
 
25
- const { handleResponse, handleResponseReturnNull } = API;
25
+ const { handleExportResponse, handleResponse, handleResponseReturnNull } = API;
26
26
 
27
27
  export function createNetworkRequests(): EditRequests & RunRequests {
28
28
  const getClient = once(() => {
@@ -415,7 +415,7 @@ export function createNetworkRequests(): EditRequests & RunRequests {
415
415
  parseAs: "text",
416
416
  params: getParams(),
417
417
  })
418
- .then(handleResponse);
418
+ .then(handleExportResponse);
419
419
  },
420
420
  exportAsMarkdown: async (request) => {
421
421
  return getClient()
@@ -424,7 +424,7 @@ export function createNetworkRequests(): EditRequests & RunRequests {
424
424
  parseAs: "text",
425
425
  params: getParams(),
426
426
  })
427
- .then(handleResponse);
427
+ .then(handleExportResponse);
428
428
  },
429
429
  exportAsIPYNB: async (request) => {
430
430
  return getClient()
@@ -433,7 +433,7 @@ export function createNetworkRequests(): EditRequests & RunRequests {
433
433
  parseAs: "text",
434
434
  params: getParams(),
435
435
  })
436
- .then(handleResponse);
436
+ .then(handleExportResponse);
437
437
  },
438
438
  exportAsPDF: async (request) => {
439
439
  return getClient()
@@ -442,7 +442,7 @@ export function createNetworkRequests(): EditRequests & RunRequests {
442
442
  parseAs: "blob",
443
443
  params: getParams(),
444
444
  })
445
- .then(handleResponse);
445
+ .then(handleExportResponse);
446
446
  },
447
447
  autoExportAsHTML: async (request) => {
448
448
  return getClient()
@@ -23,6 +23,13 @@ export type ExportAsIPYNBRequest = schemas["ExportAsIPYNBRequest"];
23
23
  export type ExportAsScriptRequest = schemas["ExportAsScriptRequest"];
24
24
  export type ExportAsPDFRequest = schemas["ExportAsPDFRequest"];
25
25
  export type UpdateCellOutputsRequest = schemas["UpdateCellOutputsRequest"];
26
+
27
+ export interface ExportedFile<T extends BlobPart = BlobPart> {
28
+ contents: T;
29
+ filename: string;
30
+ mediaType: string;
31
+ }
32
+
26
33
  export type FileCopyRequest = schemas["FileCopyRequest"];
27
34
  export type FileCopyResponse = schemas["FileCopyResponse"];
28
35
  export type FileCreateRequest = schemas["FileCreateRequest"];
@@ -205,10 +212,14 @@ export interface EditRequests {
205
212
  request: ShutdownSessionRequest,
206
213
  ) => Promise<RunningNotebooksResponse>;
207
214
  // Export requests
208
- exportAsHTML: (request: ExportAsHTMLRequest) => Promise<string>;
209
- exportAsIPYNB: (request: ExportAsIPYNBRequest) => Promise<string>;
210
- exportAsMarkdown: (request: ExportAsMarkdownRequest) => Promise<string>;
211
- exportAsPDF: (request: ExportAsPDFRequest) => Promise<Blob>;
215
+ exportAsHTML: (request: ExportAsHTMLRequest) => Promise<ExportedFile<string>>;
216
+ exportAsIPYNB: (
217
+ request: ExportAsIPYNBRequest,
218
+ ) => Promise<ExportedFile<string>>;
219
+ exportAsMarkdown: (
220
+ request: ExportAsMarkdownRequest,
221
+ ) => Promise<ExportedFile<string>>;
222
+ exportAsPDF: (request: ExportAsPDFRequest) => Promise<ExportedFile<Blob>>;
212
223
  autoExportAsHTML: (request: ExportAsHTMLRequest) => Promise<null>;
213
224
  autoExportAsMarkdown: (request: ExportAsMarkdownRequest) => Promise<null>;
214
225
  autoExportAsIPYNB: (request: ExportAsIPYNBRequest) => Promise<null>;
@@ -1,31 +1,22 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { downloadBlob } from "@/utils/download";
4
- import { Filenames } from "@/utils/filenames";
5
- import { Paths } from "@/utils/paths";
3
+ import { downloadExportedFile } from "@/utils/download";
6
4
  import { getRequestClient } from "../network/requests";
7
5
  import { VirtualFileTracker } from "./virtual-file-tracker";
8
6
 
9
7
  /**
10
8
  * Downloads the current notebook as an HTML file.
11
9
  */
12
- export async function downloadAsHTML(opts: {
13
- filename: string;
14
- includeCode: boolean;
15
- }) {
10
+ export async function downloadAsHTML(opts: { includeCode: boolean }) {
16
11
  const client = getRequestClient();
17
- const { filename, includeCode } = opts;
18
- const html = await client.exportAsHTML({
12
+ const { includeCode } = opts;
13
+ const exportedFile = await client.exportAsHTML({
19
14
  download: true,
20
15
  includeCode: includeCode,
21
16
  files: VirtualFileTracker.INSTANCE.filenames(),
22
17
  });
23
- const filenameWithoutPath = Paths.basename(filename) ?? "notebook.py";
24
18
 
25
- downloadBlob(
26
- new Blob([html], { type: "text/html" }),
27
- Filenames.toHTML(filenameWithoutPath),
28
- );
19
+ downloadExportedFile(exportedFile);
29
20
  }
30
21
 
31
22
  function updateAssetUrl(existingUrl: string, assetBaseUrl: string) {
@@ -19,6 +19,7 @@ import type {
19
19
  EnvironmentInfo,
20
20
  ExportAsHTMLRequest,
21
21
  ExportAsMarkdownRequest,
22
+ ExportedFile,
22
23
  FileCopyResponse,
23
24
  FileCreateResponse,
24
25
  FileDeleteResponse,
@@ -520,7 +521,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
520
521
  functionName: "export_html",
521
522
  payload: request,
522
523
  });
523
- return response as string;
524
+ return response as ExportedFile<string>;
524
525
  };
525
526
 
526
527
  exportAsMarkdown: EditRequests["exportAsMarkdown"] = async (
@@ -530,7 +531,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
530
531
  functionName: "export_markdown",
531
532
  payload: request,
532
533
  });
533
- return response as string;
534
+ return response as ExportedFile<string>;
534
535
  };
535
536
 
536
537
  previewDatasetColumn: EditRequests["previewDatasetColumn"] = async (
@@ -12,6 +12,7 @@ import type {
12
12
  EnvironmentInfo,
13
13
  ExportAsHTMLRequest,
14
14
  ExportAsMarkdownRequest,
15
+ ExportedFile,
15
16
  FileCopyRequest,
16
17
  FileCopyResponse,
17
18
  FileCreateRequest,
@@ -96,8 +97,10 @@ export interface RawBridge {
96
97
  load_packages(request: string): Promise<string>;
97
98
  read_file(request: string): Promise<string>;
98
99
  set_interrupt_buffer(request: Uint8Array): Promise<string>;
99
- export_html(request: ExportAsHTMLRequest): Promise<string>;
100
- export_markdown(request: ExportAsMarkdownRequest): Promise<string>;
100
+ export_html(request: ExportAsHTMLRequest): Promise<ExportedFile<string>>;
101
+ export_markdown(
102
+ request: ExportAsMarkdownRequest,
103
+ ): Promise<ExportedFile<string>>;
101
104
  }
102
105
 
103
106
  export type BridgePayload<T extends keyof RawBridge> = T extends keyof RawBridge
@@ -8,6 +8,7 @@ import {
8
8
  downloadAsPDF,
9
9
  downloadByURL,
10
10
  downloadCellOutputAsImage,
11
+ downloadExportedFile,
11
12
  downloadHTMLAsImage,
12
13
  getImageDataUrlForCell,
13
14
  withLoadingToast,
@@ -195,7 +196,6 @@ describe("downloadAsPDF", () => {
195
196
 
196
197
  await expect(
197
198
  downloadAsPDF({
198
- filename: "path/to/notebook.py",
199
199
  webpdf: false,
200
200
  preset: "slides",
201
201
  }),
@@ -572,6 +572,7 @@ describe("downloadByURL", () => {
572
572
 
573
573
  afterEach(() => {
574
574
  vi.restoreAllMocks();
575
+ vi.unstubAllGlobals();
575
576
  });
576
577
 
577
578
  it("should create anchor, set attributes, click, and remove", () => {
@@ -583,4 +584,26 @@ describe("downloadByURL", () => {
583
584
  expect(mockAnchor.click).toHaveBeenCalled();
584
585
  expect(mockAnchor.remove).toHaveBeenCalled();
585
586
  });
587
+
588
+ it("downloads an exported file with its returned metadata", async () => {
589
+ let downloadedBlob: Blob | undefined;
590
+ vi.stubGlobal("URL", {
591
+ createObjectURL: vi.fn((blob: Blob) => {
592
+ downloadedBlob = blob;
593
+ return "blob:export";
594
+ }),
595
+ revokeObjectURL: vi.fn(),
596
+ });
597
+
598
+ downloadExportedFile({
599
+ contents: "notebook",
600
+ filename: "résumé.qmd",
601
+ mediaType: "text/plain; charset=utf-8",
602
+ });
603
+
604
+ expect(downloadedBlob?.type).toBe("text/plain; charset=utf-8");
605
+ expect(await downloadedBlob?.text()).toBe("notebook");
606
+ expect(mockAnchor.download).toBe("résumé.qmd");
607
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:export");
608
+ });
586
609
  });
@@ -4,8 +4,8 @@ import React from "react";
4
4
  import { toast } from "@/components/ui/use-toast";
5
5
  import { type CellId, CellOutputId } from "@/core/cells/ids";
6
6
  import { getRequestClient } from "@/core/network/requests";
7
+ import type { ExportedFile } from "@/core/network/types";
7
8
  import { Filenames } from "@/utils/filenames";
8
- import { Paths } from "@/utils/paths";
9
9
  import { prettyError } from "./errors";
10
10
  import { toPng } from "./html-to-image";
11
11
  import { captureExternalIframes } from "./iframe";
@@ -191,6 +191,14 @@ export function downloadBlob(blob: Blob, filename: string) {
191
191
  URL.revokeObjectURL(url);
192
192
  }
193
193
 
194
+ export function downloadExportedFile({
195
+ contents,
196
+ filename,
197
+ mediaType,
198
+ }: ExportedFile) {
199
+ downloadBlob(new Blob([contents], { type: mediaType }), filename);
200
+ }
201
+
194
202
  export type PDFExportPreset = "document" | "slides";
195
203
 
196
204
  /**
@@ -200,7 +208,6 @@ export type PDFExportPreset = "document" | "slides";
200
208
  * Standard PDF requires Pandoc & TeX (~few GBs) but is of higher quality.
201
209
  */
202
210
  export async function downloadAsPDF(opts: {
203
- filename: string;
204
211
  webpdf: boolean;
205
212
  preset?: PDFExportPreset;
206
213
  includeInputs?: boolean;
@@ -210,7 +217,6 @@ export async function downloadAsPDF(opts: {
210
217
  }) {
211
218
  const client = getRequestClient();
212
219
  const {
213
- filename,
214
220
  webpdf,
215
221
  preset = "document",
216
222
  includeInputs = true,
@@ -220,7 +226,7 @@ export async function downloadAsPDF(opts: {
220
226
  } = opts;
221
227
 
222
228
  try {
223
- const pdfBlob = await client.exportAsPDF({
229
+ const exportedFile = await client.exportAsPDF({
224
230
  webpdf,
225
231
  preset,
226
232
  includeInputs,
@@ -229,8 +235,7 @@ export async function downloadAsPDF(opts: {
229
235
  rasterServer,
230
236
  });
231
237
 
232
- const filenameWithoutPath = Paths.basename(filename);
233
- downloadBlob(pdfBlob, Filenames.toPDF(filenameWithoutPath));
238
+ downloadExportedFile(exportedFile);
234
239
  } catch (error) {
235
240
  toast({
236
241
  title: "Failed to download",