@marimo-team/islands 0.25.3-dev235 → 0.25.3-dev238
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/{ErrorBoundary-RGblef_D.js → ErrorBoundary-D3i6vkO9.js} +1 -1
- package/dist/{code-visibility-B6k6w5Yv.js → code-visibility-BiRkdTtH.js} +1 -1
- package/dist/{common-kHb19IpK.js → common-C0sL860o.js} +2981 -2938
- package/dist/{data-grid-overlay-editor-tq8d9zYi.js → data-grid-overlay-editor-DNUVFYr0.js} +1 -1
- package/dist/{feedback-button-C3cYbO84.js → feedback-button-C8Oon8Zw.js} +2 -2
- package/dist/{glide-data-editor-C8JJjZYW.js → glide-data-editor-CgET7wQ0.js} +3 -3
- package/dist/{globals-QLC4ey9X.js → globals-CYR7s2Ps.js} +1 -1
- package/dist/main.js +5853 -5856
- package/dist/{number-overlay-editor-Bf0ScSaJ.js → number-overlay-editor-BBdjsw__.js} +1 -1
- package/dist/{reveal-component-DZ-vq22P.js → reveal-component-D8Kj0NZQ.js} +33 -33
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/ui/__tests__/fullscreen.test.tsx +44 -2
- package/src/components/ui/fullscreen.tsx +25 -1
- package/src/core/MarimoApp.tsx +2 -0
- package/src/css/common.css +1 -1
- package/src/plugins/layout/DownloadPlugin.tsx +7 -11
- package/src/plugins/layout/__test__/DownloadPlugin.test.tsx +196 -0
- package/src/utils/__tests__/download.test.tsx +62 -9
- package/src/utils/download.ts +55 -3
package/package.json
CHANGED
|
@@ -1,8 +1,50 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import { act, render } from "@testing-library/react";
|
|
2
|
+
import { act, render, renderHook } from "@testing-library/react";
|
|
3
3
|
import { useRef } from "react";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { useIsFullScreen } from "../fullscreen";
|
|
5
|
+
import { useFullScreenClass, useIsFullScreen } from "../fullscreen";
|
|
6
|
+
|
|
7
|
+
describe("useFullScreenClass", () => {
|
|
8
|
+
let fullscreenElement: Element | null = null;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
fullscreenElement = null;
|
|
12
|
+
Object.defineProperty(document, "fullscreenElement", {
|
|
13
|
+
configurable: true,
|
|
14
|
+
get: () => fullscreenElement,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
Reflect.deleteProperty(document, "fullscreenElement");
|
|
20
|
+
document.documentElement.classList.remove("marimo-fullscreen");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("reflects fullscreen state at mount and on entry and exit", () => {
|
|
24
|
+
fullscreenElement = document.body;
|
|
25
|
+
const { unmount } = renderHook(useFullScreenClass);
|
|
26
|
+
expect(document.documentElement).toHaveClass("marimo-fullscreen");
|
|
27
|
+
|
|
28
|
+
fullscreenElement = null;
|
|
29
|
+
act(() => document.dispatchEvent(new Event("fullscreenchange")));
|
|
30
|
+
expect(document.documentElement).not.toHaveClass("marimo-fullscreen");
|
|
31
|
+
|
|
32
|
+
fullscreenElement = document.body;
|
|
33
|
+
act(() => document.dispatchEvent(new Event("fullscreenchange")));
|
|
34
|
+
expect(document.documentElement).toHaveClass("marimo-fullscreen");
|
|
35
|
+
unmount();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("removes the class and listener on unmount", () => {
|
|
39
|
+
fullscreenElement = document.body;
|
|
40
|
+
const { unmount } = renderHook(useFullScreenClass);
|
|
41
|
+
unmount();
|
|
42
|
+
expect(document.documentElement).not.toHaveClass("marimo-fullscreen");
|
|
43
|
+
|
|
44
|
+
act(() => document.dispatchEvent(new Event("fullscreenchange")));
|
|
45
|
+
expect(document.documentElement).not.toHaveClass("marimo-fullscreen");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
6
48
|
|
|
7
49
|
const FullScreenFlag = ({ label }: { label: string }) => {
|
|
8
50
|
const ref = useRef<HTMLDivElement>(null);
|
|
@@ -4,7 +4,12 @@ import type { Popover } from "radix-ui";
|
|
|
4
4
|
|
|
5
5
|
type PopoverContentProps = Popover.PopoverContentProps;
|
|
6
6
|
|
|
7
|
-
import React, {
|
|
7
|
+
import React, {
|
|
8
|
+
type RefObject,
|
|
9
|
+
useLayoutEffect,
|
|
10
|
+
useState,
|
|
11
|
+
useSyncExternalStore,
|
|
12
|
+
} from "react";
|
|
8
13
|
import { isInVscodeExtension } from "@/core/vscode/is-in-vscode";
|
|
9
14
|
import { useEventListener } from "@/hooks/useEventListener";
|
|
10
15
|
|
|
@@ -14,6 +19,25 @@ const VSCODE_OUTPUT_CONTAINER_SELECTOR = "[data-vscode-output-container]";
|
|
|
14
19
|
// Otherwise, we give a 30px buffer to the max-height.
|
|
15
20
|
export const MAX_HEIGHT_OFFSET = isInVscodeExtension() ? 0 : 30;
|
|
16
21
|
|
|
22
|
+
// An explicit class avoids document-wide :has(:fullscreen) style invalidation.
|
|
23
|
+
export function useFullScreenClass() {
|
|
24
|
+
useLayoutEffect(() => {
|
|
25
|
+
const syncFullScreenClass = () => {
|
|
26
|
+
document.documentElement.classList.toggle(
|
|
27
|
+
"marimo-fullscreen",
|
|
28
|
+
Boolean(document.fullscreenElement),
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
syncFullScreenClass();
|
|
33
|
+
document.addEventListener("fullscreenchange", syncFullScreenClass);
|
|
34
|
+
return () => {
|
|
35
|
+
document.removeEventListener("fullscreenchange", syncFullScreenClass);
|
|
36
|
+
document.documentElement.classList.remove("marimo-fullscreen");
|
|
37
|
+
};
|
|
38
|
+
}, []);
|
|
39
|
+
}
|
|
40
|
+
|
|
17
41
|
/**
|
|
18
42
|
* Get the full screen element if we are in full screen mode
|
|
19
43
|
*/
|
package/src/core/MarimoApp.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { Provider as SlotzProvider } from "@marimo-team/react-slotz";
|
|
|
7
7
|
import type React from "react";
|
|
8
8
|
import { memo, type PropsWithChildren, Suspense } from "react";
|
|
9
9
|
import { TailwindIndicator } from "@/components/debug/indicator";
|
|
10
|
+
import { useFullScreenClass } from "@/components/ui/fullscreen";
|
|
10
11
|
import { useAppConfig, useResolvedMarimoConfig } from "@/core/config/config";
|
|
11
12
|
import { getInitialAppMode } from "@/core/mode";
|
|
12
13
|
import { CssVariables } from "@/theme/ThemeProvider";
|
|
@@ -58,6 +59,7 @@ export function preloadPage(mode: string) {
|
|
|
58
59
|
* The root component of the Marimo app.
|
|
59
60
|
*/
|
|
60
61
|
export const MarimoApp: React.FC = memo(() => {
|
|
62
|
+
useFullScreenClass();
|
|
61
63
|
const [userConfig] = useResolvedMarimoConfig();
|
|
62
64
|
const appConfig = useAppConfig();
|
|
63
65
|
const editorFontSize = toRem(userConfig.display.code_editor_font_size);
|
package/src/css/common.css
CHANGED
|
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { buttonVariants } from "@/components/ui/button";
|
|
7
7
|
import { toast } from "@/components/ui/use-toast";
|
|
8
8
|
import { cn } from "@/utils/cn";
|
|
9
|
-
import { downloadByURL } from "@/utils/download";
|
|
9
|
+
import { downloadByURL, isDownloadUrl } from "@/utils/download";
|
|
10
10
|
import { Logger } from "@/utils/Logger";
|
|
11
11
|
import { createPlugin } from "../core/builder";
|
|
12
12
|
import { renderHTML } from "../core/RenderHTML";
|
|
@@ -80,7 +80,7 @@ const DownloadButton = ({
|
|
|
80
80
|
const [isLoading, setIsLoading] = useState(false);
|
|
81
81
|
|
|
82
82
|
const handleClick = async (e: React.MouseEvent) => {
|
|
83
|
-
if (data.disabled) {
|
|
83
|
+
if (data.disabled || isLoading) {
|
|
84
84
|
e.preventDefault();
|
|
85
85
|
e.stopPropagation();
|
|
86
86
|
return;
|
|
@@ -88,15 +88,11 @@ const DownloadButton = ({
|
|
|
88
88
|
|
|
89
89
|
e.preventDefault();
|
|
90
90
|
|
|
91
|
-
if (!data.lazy) {
|
|
92
|
-
downloadByURL(data.data, data.filename ?? undefined);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
setIsLoading(true);
|
|
97
|
-
|
|
98
91
|
try {
|
|
99
|
-
|
|
92
|
+
if (data.lazy) {
|
|
93
|
+
setIsLoading(true);
|
|
94
|
+
}
|
|
95
|
+
const loadedData = data.lazy ? await load({}) : data;
|
|
100
96
|
downloadByURL(
|
|
101
97
|
loadedData.data || data.data,
|
|
102
98
|
loadedData.filename ?? data.filename ?? undefined,
|
|
@@ -117,7 +113,7 @@ const DownloadButton = ({
|
|
|
117
113
|
|
|
118
114
|
return (
|
|
119
115
|
<a
|
|
120
|
-
href={data.data}
|
|
116
|
+
href={isDownloadUrl(data.data) ? data.data : undefined}
|
|
121
117
|
download={data.filename ?? ""}
|
|
122
118
|
onClick={handleClick}
|
|
123
119
|
className={buttonVariants({
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
act,
|
|
5
|
+
cleanup,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from "@testing-library/react";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { Mocks } from "@/__mocks__/common";
|
|
12
|
+
import { toast } from "@/components/ui/use-toast";
|
|
13
|
+
import { assertExists } from "@/utils/assertExists";
|
|
14
|
+
import { Deferred } from "@/utils/Deferred";
|
|
15
|
+
import { registerReactComponent } from "../../core/registerReactComponent";
|
|
16
|
+
import { sanitizeHtml } from "../../core/sanitize-html";
|
|
17
|
+
import { DownloadPlugin } from "../DownloadPlugin";
|
|
18
|
+
|
|
19
|
+
vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() }));
|
|
20
|
+
vi.mock("@/utils/Logger", () => ({ Logger: Mocks.quietLogger() }));
|
|
21
|
+
|
|
22
|
+
type DownloadData = Parameters<typeof DownloadPlugin.render>[0]["data"];
|
|
23
|
+
type Load = Parameters<typeof DownloadPlugin.render>[0]["functions"]["load"];
|
|
24
|
+
|
|
25
|
+
function renderDownload(
|
|
26
|
+
data: Partial<DownloadData> = {},
|
|
27
|
+
load = vi.fn<Load>(),
|
|
28
|
+
) {
|
|
29
|
+
const result = render(
|
|
30
|
+
DownloadPlugin.render({
|
|
31
|
+
host: document.createElement("div"),
|
|
32
|
+
value: null,
|
|
33
|
+
setValue: vi.fn(),
|
|
34
|
+
data: DownloadPlugin.validator.parse({
|
|
35
|
+
data: "./@file/123-results.csv",
|
|
36
|
+
filename: "results.csv",
|
|
37
|
+
...data,
|
|
38
|
+
}),
|
|
39
|
+
functions: { load },
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
const button = result.getByText("Download").closest("a");
|
|
43
|
+
assertExists(button);
|
|
44
|
+
return { ...result, load, button };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("DownloadPlugin", () => {
|
|
48
|
+
const downloads: { href: string | null; filename: string | null }[] = [];
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
downloads.length = 0;
|
|
52
|
+
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
|
53
|
+
this: HTMLAnchorElement,
|
|
54
|
+
) {
|
|
55
|
+
downloads.push({
|
|
56
|
+
href: this.getAttribute("href"),
|
|
57
|
+
filename: this.getAttribute("download"),
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
cleanup();
|
|
64
|
+
vi.restoreAllMocks();
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("downloads immediately without calling load", () => {
|
|
69
|
+
const { button, load } = renderDownload();
|
|
70
|
+
fireEvent.click(button);
|
|
71
|
+
|
|
72
|
+
expect(downloads).toEqual([
|
|
73
|
+
{ href: "./@file/123-results.csv", filename: "results.csv" },
|
|
74
|
+
]);
|
|
75
|
+
expect(load).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it.each([
|
|
79
|
+
{ data: "data:text/plain,hello", filename: "loaded.txt" },
|
|
80
|
+
{ data: "", filename: "renamed.csv" },
|
|
81
|
+
{ data: "data:text/plain,hello", filename: null },
|
|
82
|
+
])("downloads lazy data with metadata fallbacks: %j", async (loadedData) => {
|
|
83
|
+
const load = vi.fn<Load>().mockResolvedValue(loadedData);
|
|
84
|
+
const { button } = renderDownload({ lazy: true }, load);
|
|
85
|
+
fireEvent.click(button);
|
|
86
|
+
|
|
87
|
+
await waitFor(() => {
|
|
88
|
+
expect(downloads).toEqual([
|
|
89
|
+
{
|
|
90
|
+
href: loadedData.data || "./@file/123-results.csv",
|
|
91
|
+
filename: loadedData.filename ?? "results.csv",
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
expect(load).toHaveBeenCalledExactlyOnceWith({});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it.each([false, true])("ignores disabled clicks (lazy=%s)", (lazy) => {
|
|
99
|
+
const { button, load } = renderDownload({ disabled: true, lazy });
|
|
100
|
+
fireEvent.click(button);
|
|
101
|
+
expect(downloads).toEqual([]);
|
|
102
|
+
expect(load).not.toHaveBeenCalled();
|
|
103
|
+
expect(toast).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it.each([false, true])(
|
|
107
|
+
"handles an unsupported URL (lazy=%s)",
|
|
108
|
+
async (lazy) => {
|
|
109
|
+
const data = "java\nscript:alert(1)";
|
|
110
|
+
const load = vi.fn<Load>().mockResolvedValue({ data });
|
|
111
|
+
const { button } = renderDownload({ data, lazy }, load);
|
|
112
|
+
expect(button.hasAttribute("href")).toBe(false);
|
|
113
|
+
fireEvent.click(button);
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(toast).toHaveBeenCalledWith({
|
|
117
|
+
title: "Failed to download",
|
|
118
|
+
description: "Please try again.",
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
expect(downloads).toEqual([]);
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
it("validates the lazy response even when the initial URL is valid", async () => {
|
|
126
|
+
const load = vi
|
|
127
|
+
.fn<Load>()
|
|
128
|
+
.mockResolvedValue({ data: "javascript:alert(1)" });
|
|
129
|
+
const { button } = renderDownload({ lazy: true }, load);
|
|
130
|
+
fireEvent.click(button);
|
|
131
|
+
|
|
132
|
+
await waitFor(() => expect(toast).toHaveBeenCalledOnce());
|
|
133
|
+
expect(downloads).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("validates the fallback URL when lazy loading only returns a filename", async () => {
|
|
137
|
+
const load = vi
|
|
138
|
+
.fn<Load>()
|
|
139
|
+
.mockResolvedValue({ data: "", filename: "file.txt" });
|
|
140
|
+
const { button } = renderDownload(
|
|
141
|
+
{ data: "javascript:alert(1)", lazy: true },
|
|
142
|
+
load,
|
|
143
|
+
);
|
|
144
|
+
fireEvent.click(button);
|
|
145
|
+
|
|
146
|
+
await waitFor(() => expect(toast).toHaveBeenCalledOnce());
|
|
147
|
+
expect(downloads).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("ignores clicks while loading and allows retry after a failed load", async () => {
|
|
151
|
+
const pendingLoad = new Deferred<Awaited<ReturnType<Load>>>();
|
|
152
|
+
const load = vi
|
|
153
|
+
.fn<Load>()
|
|
154
|
+
.mockReturnValueOnce(pendingLoad.promise)
|
|
155
|
+
.mockResolvedValue({ data: "data:text/plain,retry" });
|
|
156
|
+
const { button } = renderDownload({ data: "", lazy: true }, load);
|
|
157
|
+
fireEvent.click(button);
|
|
158
|
+
fireEvent.click(button);
|
|
159
|
+
expect(load).toHaveBeenCalledOnce();
|
|
160
|
+
|
|
161
|
+
await act(async () => pendingLoad.reject(new Error("Load failed")));
|
|
162
|
+
expect(toast).toHaveBeenCalledOnce();
|
|
163
|
+
expect(downloads).toEqual([]);
|
|
164
|
+
|
|
165
|
+
fireEvent.click(button);
|
|
166
|
+
await waitFor(() =>
|
|
167
|
+
expect(downloads).toEqual([
|
|
168
|
+
{ href: "data:text/plain,retry", filename: "results.csv" },
|
|
169
|
+
]),
|
|
170
|
+
);
|
|
171
|
+
expect(load).toHaveBeenCalledTimes(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("rejects an unsupported URL restored from sanitized output", async () => {
|
|
175
|
+
registerReactComponent(DownloadPlugin);
|
|
176
|
+
const container = document.createElement("div");
|
|
177
|
+
container.innerHTML = sanitizeHtml(
|
|
178
|
+
`<marimo-download data-data='"javascript:alert(1)"' data-initial-value='null' data-disabled='false' data-lazy='false'></marimo-download>`,
|
|
179
|
+
);
|
|
180
|
+
try {
|
|
181
|
+
await act(async () => {
|
|
182
|
+
document.body.append(container);
|
|
183
|
+
});
|
|
184
|
+
const button = container
|
|
185
|
+
.querySelector("marimo-download")
|
|
186
|
+
?.shadowRoot?.querySelector("a");
|
|
187
|
+
assertExists(button);
|
|
188
|
+
expect(button.hasAttribute("href")).toBe(false);
|
|
189
|
+
fireEvent.click(button);
|
|
190
|
+
expect(toast).toHaveBeenCalledOnce();
|
|
191
|
+
expect(downloads).toEqual([]);
|
|
192
|
+
} finally {
|
|
193
|
+
await act(async () => container.remove());
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -595,24 +595,77 @@ describe("downloadByURL", () => {
|
|
|
595
595
|
});
|
|
596
596
|
|
|
597
597
|
it("should create anchor, set attributes, click, and remove", () => {
|
|
598
|
-
downloadByURL("data:
|
|
598
|
+
downloadByURL("data:image/png;base64,AAA=", "filename.png");
|
|
599
599
|
|
|
600
600
|
expect(document.createElement).toHaveBeenCalledWith("a");
|
|
601
|
-
expect(mockAnchor.href).toBe("data:
|
|
601
|
+
expect(mockAnchor.href).toBe("data:image/png;base64,AAA=");
|
|
602
602
|
expect(mockAnchor.download).toBe("filename.png");
|
|
603
603
|
expect(mockAnchor.click).toHaveBeenCalled();
|
|
604
604
|
expect(mockAnchor.remove).toHaveBeenCalled();
|
|
605
605
|
});
|
|
606
606
|
|
|
607
|
+
it.each([
|
|
608
|
+
"https://example.com/results.csv",
|
|
609
|
+
"http://example.com/results.csv",
|
|
610
|
+
"//example.com/results.csv",
|
|
611
|
+
"./@file/123-results.csv",
|
|
612
|
+
"/files/results.csv?download=1#data",
|
|
613
|
+
"../results.csv",
|
|
614
|
+
"blob:http://localhost/123",
|
|
615
|
+
"data:text/csv;charset=utf-8,a%2Cb",
|
|
616
|
+
"DATA:TEXT/PLAIN;base64,aGVsbG8=",
|
|
617
|
+
"data:application/json,%7B%7D",
|
|
618
|
+
"data:application/octet-stream;base64,AAA=",
|
|
619
|
+
"data:,hello",
|
|
620
|
+
"data:;base64,aGVsbG8=",
|
|
621
|
+
"data:text/plain,",
|
|
622
|
+
" https://example.com/results.csv ",
|
|
623
|
+
])("downloads %s without an explicit filename", (url) => {
|
|
624
|
+
downloadByURL(url);
|
|
625
|
+
|
|
626
|
+
expect(mockAnchor.getAttribute("href")).toBe(url);
|
|
627
|
+
expect(mockAnchor.getAttribute("download")).toBe("");
|
|
628
|
+
expect(mockAnchor.click).toHaveBeenCalledOnce();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it.each([
|
|
632
|
+
"javascript:alert(1)",
|
|
633
|
+
"JaVaScRiPt:alert(1)",
|
|
634
|
+
" \u0000javascript:alert(1)",
|
|
635
|
+
"java\tscript:alert(1)",
|
|
636
|
+
"java\nscript:alert(1)",
|
|
637
|
+
"java\rscript:alert(1)",
|
|
638
|
+
"vbscript:msgbox(1)",
|
|
639
|
+
"file:///tmp/results.csv",
|
|
640
|
+
"ftp://example.com/results.csv",
|
|
641
|
+
"mailto:example@example.com",
|
|
642
|
+
"data:text/html,<h1>hello</h1>",
|
|
643
|
+
"data:TEXT/HTML;charset=utf-8;base64,AAA=",
|
|
644
|
+
"data:image/svg+xml,<svg/>",
|
|
645
|
+
"data:application/xhtml+xml,<html/>",
|
|
646
|
+
"data:text/javascript,alert(1)",
|
|
647
|
+
"data:text/plain",
|
|
648
|
+
"https://[invalid",
|
|
649
|
+
"",
|
|
650
|
+
" \n\t",
|
|
651
|
+
])("rejects %j before creating a download link", (url) => {
|
|
652
|
+
expect(() => downloadByURL(url)).toThrow("Unsupported download URL");
|
|
653
|
+
expect(document.createElement).not.toHaveBeenCalled();
|
|
654
|
+
expect(mockAnchor.click).not.toHaveBeenCalled();
|
|
655
|
+
});
|
|
656
|
+
|
|
607
657
|
it("downloads an exported file with its returned metadata", async () => {
|
|
608
658
|
let downloadedBlob: Blob | undefined;
|
|
609
|
-
vi.stubGlobal(
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
659
|
+
vi.stubGlobal(
|
|
660
|
+
"URL",
|
|
661
|
+
class extends URL {
|
|
662
|
+
public static override createObjectURL = vi.fn((blob: Blob) => {
|
|
663
|
+
downloadedBlob = blob;
|
|
664
|
+
return "blob:export";
|
|
665
|
+
});
|
|
666
|
+
public static override revokeObjectURL = vi.fn();
|
|
667
|
+
},
|
|
668
|
+
);
|
|
616
669
|
|
|
617
670
|
downloadExportedFile({
|
|
618
671
|
contents: "notebook",
|
package/src/utils/download.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { captureExternalIframes } from "./iframe";
|
|
|
12
12
|
import { Logger } from "./Logger";
|
|
13
13
|
import { ProgressState } from "./progress";
|
|
14
14
|
import { ToastProgress } from "./toast-progress";
|
|
15
|
+
import { asURL } from "./url";
|
|
15
16
|
|
|
16
17
|
const FINISH_TOAST_DURATION_MS = 1200;
|
|
17
18
|
type ToastConfig = Omit<Parameters<typeof toast>[0], "id">;
|
|
@@ -177,12 +178,63 @@ export async function downloadHTMLAsImage(opts: {
|
|
|
177
178
|
}
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
const DOWNLOAD_DATA_TYPES = new Set([
|
|
182
|
+
"text/plain",
|
|
183
|
+
"text/csv",
|
|
184
|
+
"text/tab-separated-values",
|
|
185
|
+
"application/json",
|
|
186
|
+
"application/octet-stream",
|
|
187
|
+
"application/pdf",
|
|
188
|
+
"application/zip",
|
|
189
|
+
"application/gzip",
|
|
190
|
+
"image/png",
|
|
191
|
+
"image/jpeg",
|
|
192
|
+
"image/gif",
|
|
193
|
+
"image/webp",
|
|
194
|
+
"image/avif",
|
|
195
|
+
"image/bmp",
|
|
196
|
+
"image/x-icon",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
export function isDownloadUrl(url: string): boolean {
|
|
200
|
+
if (!url.trim()) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const parsed = asURL(url);
|
|
205
|
+
switch (parsed.protocol) {
|
|
206
|
+
case "http:":
|
|
207
|
+
case "https:":
|
|
208
|
+
case "blob:":
|
|
209
|
+
return true;
|
|
210
|
+
case "data:": {
|
|
211
|
+
const comma = parsed.pathname.indexOf(",");
|
|
212
|
+
if (comma === -1) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const mimeType = parsed.pathname
|
|
216
|
+
.slice(0, comma)
|
|
217
|
+
.split(";", 1)[0]
|
|
218
|
+
.trim()
|
|
219
|
+
.toLowerCase();
|
|
220
|
+
// An omitted MIME type defaults to text/plain.
|
|
221
|
+
return DOWNLOAD_DATA_TYPES.has(mimeType || "text/plain");
|
|
222
|
+
}
|
|
223
|
+
default:
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
180
231
|
export function downloadByURL(url: string, filename?: string) {
|
|
232
|
+
if (!isDownloadUrl(url)) {
|
|
233
|
+
throw new Error("Unsupported download URL");
|
|
234
|
+
}
|
|
181
235
|
const a = document.createElement("a");
|
|
182
236
|
a.href = url;
|
|
183
|
-
|
|
184
|
-
a.download = filename;
|
|
185
|
-
}
|
|
237
|
+
a.download = filename ?? "";
|
|
186
238
|
a.click();
|
|
187
239
|
a.remove();
|
|
188
240
|
}
|