@marimo-team/islands 0.19.7-dev20 → 0.19.7-dev21

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.
package/dist/main.js CHANGED
@@ -72913,7 +72913,7 @@ Image URL: ${r.imageUrl}`)), contextToXml({
72913
72913
  return Logger.warn("Failed to get version from mount config"), null;
72914
72914
  }
72915
72915
  }
72916
- const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.7-dev20"), showCodeInRunModeAtom = atom(true);
72916
+ const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.7-dev21"), showCodeInRunModeAtom = atom(true);
72917
72917
  atom(null);
72918
72918
  var import_compiler_runtime$89 = require_compiler_runtime();
72919
72919
  function useKeydownOnElement(e, r) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.19.7-dev20",
3
+ "version": "0.19.7-dev21",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -0,0 +1,139 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { captureIframeAsImage } from "../iframe";
4
+
5
+ // Mock html-to-image
6
+ vi.mock("html-to-image", () => ({
7
+ toPng: vi.fn().mockResolvedValue("data:image/png;base64,mock"),
8
+ }));
9
+
10
+ describe("captureIframeAsImage", () => {
11
+ const originalCreateElement = document.createElement.bind(document);
12
+
13
+ beforeEach(() => {
14
+ // Mock devicePixelRatio
15
+ Object.defineProperty(window, "devicePixelRatio", {
16
+ value: 1,
17
+ writable: true,
18
+ });
19
+
20
+ // Mock canvas for placeholder generation
21
+ vi.spyOn(document, "createElement").mockImplementation((tagName) => {
22
+ const element = originalCreateElement(tagName);
23
+ if (tagName === "canvas") {
24
+ const mockCtx = {
25
+ scale: vi.fn(),
26
+ fillStyle: "",
27
+ fillRect: vi.fn(),
28
+ strokeStyle: "",
29
+ strokeRect: vi.fn(),
30
+ font: "",
31
+ textAlign: "",
32
+ textBaseline: "",
33
+ fillText: vi.fn(),
34
+ measureText: vi.fn().mockReturnValue({ width: 10 }),
35
+ };
36
+ vi.spyOn(element as HTMLCanvasElement, "getContext").mockReturnValue(
37
+ mockCtx as unknown as CanvasRenderingContext2D,
38
+ );
39
+ vi.spyOn(element as HTMLCanvasElement, "toDataURL").mockReturnValue(
40
+ "data:image/png;base64,placeholder",
41
+ );
42
+ }
43
+ return element;
44
+ });
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.clearAllMocks();
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ it("should return null when element has no iframe", async () => {
53
+ const element = document.createElement("div");
54
+ element.innerHTML = "<p>No iframe here</p>";
55
+
56
+ const result = await captureIframeAsImage(element);
57
+ expect(result).toBeNull();
58
+ });
59
+
60
+ it("should return placeholder for external iframe", async () => {
61
+ const element = document.createElement("div");
62
+ element.innerHTML = '<iframe src="https://external.com/page"></iframe>';
63
+
64
+ const result = await captureIframeAsImage(element);
65
+
66
+ expect(result).not.toBeNull();
67
+ expect(result).toMatch(/^data:image\/png;base64,/);
68
+ });
69
+
70
+ it("should return placeholder for cross-origin iframe", async () => {
71
+ const element = document.createElement("div");
72
+ element.innerHTML =
73
+ '<iframe src="https://www.openstreetmap.org/export/embed.html"></iframe>';
74
+
75
+ const result = await captureIframeAsImage(element);
76
+
77
+ expect(result).not.toBeNull();
78
+ expect(result).toMatch(/^data:image\/png;base64,/);
79
+ });
80
+
81
+ it("should return null for about:blank iframe without body", async () => {
82
+ const element = document.createElement("div");
83
+ const iframe = document.createElement("iframe");
84
+ iframe.src = "about:blank";
85
+ element.append(iframe);
86
+
87
+ // The iframe has no body accessible in jsdom
88
+ const result = await captureIframeAsImage(element);
89
+
90
+ // In jsdom, contentDocument may not be accessible
91
+ expect(result).toBeNull();
92
+ });
93
+
94
+ it("should handle iframe with relative same-origin src", async () => {
95
+ const element = document.createElement("div");
96
+ element.innerHTML = '<iframe src="./@file/123.html"></iframe>';
97
+
98
+ // Same-origin relative URL, but contentDocument not accessible in jsdom
99
+ const result = await captureIframeAsImage(element);
100
+
101
+ // Returns null because jsdom can't access contentDocument for file URLs
102
+ expect(result).toBeNull();
103
+ });
104
+
105
+ it("should detect external URL from various formats", async () => {
106
+ const externalUrls = [
107
+ "https://example.com",
108
+ "http://external.org/path",
109
+ "https://sub.domain.com:8080/page",
110
+ ];
111
+
112
+ for (const url of externalUrls) {
113
+ const element = document.createElement("div");
114
+ element.innerHTML = `<iframe src="${url}"></iframe>`;
115
+
116
+ const result = await captureIframeAsImage(element);
117
+
118
+ expect(result).not.toBeNull();
119
+ expect(result).toMatch(/^data:image\/png;base64,/);
120
+ }
121
+ });
122
+
123
+ it("should not treat same-origin URLs as external", async () => {
124
+ // Same-origin URLs - these should try to capture, not return placeholder
125
+ const sameOriginUrls = ["/local/path", "./relative/path", "../parent/path"];
126
+
127
+ for (const url of sameOriginUrls) {
128
+ const element = document.createElement("div");
129
+ element.innerHTML = `<iframe src="${url}"></iframe>`;
130
+
131
+ const result = await captureIframeAsImage(element);
132
+
133
+ // In jsdom, these return null because contentDocument isn't accessible
134
+ // but they should NOT return a placeholder (which would indicate external detection)
135
+ // The key is they don't trigger the external URL path
136
+ expect(result).toBeNull();
137
+ }
138
+ });
139
+ });
@@ -7,6 +7,7 @@ import { Filenames } from "@/utils/filenames";
7
7
  import { Paths } from "@/utils/paths";
8
8
  import { prettyError } from "./errors";
9
9
  import { toPng } from "./html-to-image";
10
+ import { captureIframeAsImage } from "./iframe";
10
11
  import { Logger } from "./Logger";
11
12
 
12
13
  /**
@@ -104,6 +105,12 @@ export async function getImageDataUrlForCell(
104
105
  if (!element) {
105
106
  return;
106
107
  }
108
+
109
+ const iframeDataUrl = await captureIframeAsImage(element);
110
+ if (iframeDataUrl) {
111
+ return iframeDataUrl;
112
+ }
113
+
107
114
  const cleanup = prepareCellElementForScreenshot(element, enablePrintMode);
108
115
 
109
116
  try {
@@ -125,6 +132,13 @@ export async function downloadCellOutputAsImage(
125
132
  return;
126
133
  }
127
134
 
135
+ // Cell outputs that are iframes
136
+ const iframeDataUrl = await captureIframeAsImage(element);
137
+ if (iframeDataUrl) {
138
+ downloadByURL(iframeDataUrl, Filenames.toPNG(filename));
139
+ return;
140
+ }
141
+
128
142
  await downloadHTMLAsImage({
129
143
  element,
130
144
  filename,
@@ -0,0 +1,142 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { toPng } from "./html-to-image";
4
+
5
+ const PLACEHOLDER_WIDTH = 320;
6
+ const PLACEHOLDER_HEIGHT = 180;
7
+
8
+ function isExternalUrl(src: string | null): string | null {
9
+ if (!src || src === "about:blank") {
10
+ return null;
11
+ }
12
+ try {
13
+ const resolved = new URL(src, window.location.href);
14
+ if (resolved.origin === window.location.origin) {
15
+ return null;
16
+ }
17
+ return resolved.href;
18
+ } catch {
19
+ return src;
20
+ }
21
+ }
22
+
23
+ function wrapText(
24
+ ctx: CanvasRenderingContext2D,
25
+ text: string,
26
+ maxWidth: number,
27
+ ): string[] {
28
+ const lines: string[] = [];
29
+ let current = "";
30
+
31
+ for (const char of text) {
32
+ const test = current + char;
33
+ if (ctx.measureText(test).width <= maxWidth) {
34
+ current = test;
35
+ } else {
36
+ if (current) {
37
+ lines.push(current);
38
+ }
39
+ current = char;
40
+ }
41
+ }
42
+ if (current) {
43
+ lines.push(current);
44
+ }
45
+
46
+ return lines;
47
+ }
48
+
49
+ function createPlaceholderImage(url: string | null): string {
50
+ const scale = window.devicePixelRatio || 1;
51
+ const canvas = document.createElement("canvas");
52
+ canvas.width = PLACEHOLDER_WIDTH * scale;
53
+ canvas.height = PLACEHOLDER_HEIGHT * scale;
54
+
55
+ const ctx = canvas.getContext("2d");
56
+ if (!ctx) {
57
+ return canvas.toDataURL("image/png");
58
+ }
59
+
60
+ ctx.scale(scale, scale);
61
+
62
+ // Background
63
+ ctx.fillStyle = "#f3f4f6";
64
+ ctx.fillRect(0, 0, PLACEHOLDER_WIDTH, PLACEHOLDER_HEIGHT);
65
+
66
+ // Border
67
+ ctx.strokeStyle = "#d1d5db";
68
+ ctx.strokeRect(0.5, 0.5, PLACEHOLDER_WIDTH - 1, PLACEHOLDER_HEIGHT - 1);
69
+
70
+ // Text
71
+ ctx.fillStyle = "#6b7280";
72
+ ctx.font = "8px sans-serif";
73
+ ctx.textAlign = "center";
74
+ ctx.textBaseline = "middle";
75
+
76
+ const lineHeight = 14;
77
+ const padding = 16;
78
+ const maxWidth = PLACEHOLDER_WIDTH - padding * 2;
79
+
80
+ const message = "External iframe";
81
+ const urlLines = url ? wrapText(ctx, url, maxWidth) : [];
82
+ const totalLines = 1 + urlLines.length;
83
+ const totalHeight = totalLines * lineHeight;
84
+ let y = (PLACEHOLDER_HEIGHT - totalHeight) / 2 + lineHeight / 2;
85
+
86
+ ctx.fillText(message, PLACEHOLDER_WIDTH / 2, y);
87
+ y += lineHeight;
88
+
89
+ for (const line of urlLines) {
90
+ ctx.fillText(line, PLACEHOLDER_WIDTH / 2, y);
91
+ y += lineHeight;
92
+ }
93
+
94
+ return canvas.toDataURL("image/png");
95
+ }
96
+
97
+ /**
98
+ * Capture an iframe as a PNG image. We need to do this because external iframes are not supported by html-to-image.
99
+ * @param element - The element to capture the iframe from
100
+ * @returns The image data URL of the iframe, or a placeholder image if the iframe is external
101
+ */
102
+ export async function captureIframeAsImage(
103
+ element: HTMLElement,
104
+ ): Promise<string | null> {
105
+ const iframe = element.querySelector("iframe");
106
+ if (!iframe) {
107
+ return null;
108
+ }
109
+
110
+ // Check if the iframe itself is external
111
+ const externalUrl = isExternalUrl(iframe.getAttribute("src"));
112
+ if (externalUrl) {
113
+ return createPlaceholderImage(externalUrl);
114
+ }
115
+
116
+ // Try to access iframe document and check for nested external iframes
117
+ let doc: Document;
118
+ try {
119
+ const d = iframe.contentDocument || iframe.contentWindow?.document;
120
+ if (!d?.body) {
121
+ return null;
122
+ }
123
+ doc = d;
124
+ } catch {
125
+ return createPlaceholderImage(null);
126
+ }
127
+
128
+ // Check for nested external iframes
129
+ for (const nested of doc.querySelectorAll("iframe")) {
130
+ const nestedExternal = isExternalUrl(nested.getAttribute("src"));
131
+ if (nestedExternal) {
132
+ return createPlaceholderImage(nestedExternal);
133
+ }
134
+ }
135
+
136
+ // Capture the iframe content
137
+ try {
138
+ return await toPng(doc.body);
139
+ } catch {
140
+ return createPlaceholderImage(null);
141
+ }
142
+ }