@marimo-team/islands 0.23.17-dev31 → 0.23.17-dev33
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/{chat-ui-CnjFz0bt.js → chat-ui-Cjd9kIcO.js} +56 -56
- package/dist/{common-CaGHcf3w.js → common-C2Coc2Bh.js} +629 -629
- package/dist/{html-to-image-B8KJvlou.js → html-to-image-Bgb6aaXi.js} +2154 -2150
- package/dist/main.js +668 -675
- package/dist/{process-output-CjS9wS4l.js → process-output-D2Oxf11p.js} +1 -1
- package/dist/{reveal-component-Cja7Z8N3.js → reveal-component--Ln8gdbm.js} +2 -2
- package/package.json +1 -1
- package/src/components/data-table/charts/__tests__/schemas.test.ts +35 -0
- package/src/components/data-table/charts/schemas.ts +5 -2
- package/src/components/datasets/__tests__/icons.test.ts +21 -0
- package/src/components/datasets/icons.tsx +10 -0
- package/src/components/datasources/__tests__/components.test.tsx +29 -0
- package/src/components/datasources/components.tsx +8 -3
- package/src/components/editor/actions/export-dialog/export-command.ts +4 -9
- package/src/core/codemirror/language/languages/sql/__tests__/renderers.test.tsx +44 -0
- package/src/core/codemirror/language/languages/sql/renderers.tsx +3 -3
- package/src/core/network/__tests__/api.test.ts +132 -17
- package/src/core/network/__tests__/export-filename.test.ts +43 -0
- package/src/core/network/api.ts +47 -12
- package/src/core/network/export-filename.ts +37 -0
- package/src/core/network/markdown-export.ts +19 -0
- package/src/core/network/requests-network.ts +29 -5
- package/src/core/network/types.ts +3 -0
- package/src/plugins/impl/DataEditorPlugin.tsx +2 -9
- package/src/plugins/impl/__tests__/DataEditorPlugin.test.ts +18 -0
- package/src/plugins/impl/data-frames/__tests__/schema.test.ts +30 -0
- package/src/plugins/impl/data-frames/schema.ts +4 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { s as __toESM } from "./chunk-BNovOVIE.js";
|
|
2
2
|
import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
|
|
3
|
-
import { F as base64ToDataURL, ot as ansiToPlainText, st as parseHtmlContent } from "./html-to-image-
|
|
3
|
+
import { F as base64ToDataURL, ot as ansiToPlainText, st as parseHtmlContent } from "./html-to-image-Bgb6aaXi.js";
|
|
4
4
|
import { u as createLucideIcon } from "./dist-DgzzIPS4.js";
|
|
5
5
|
import { t as Strings } from "./strings-CITcoFNY.js";
|
|
6
6
|
import { t as require_jsx_runtime } from "./jsx-runtime-DebpN0FN.js";
|
|
@@ -6,10 +6,10 @@ import { s as __toESM } from "./chunk-BNovOVIE.js";
|
|
|
6
6
|
import { c as useEventListener, g as Logger, h as cn, m as Events, t as Button } from "./button-8aS1QcHr.js";
|
|
7
7
|
import { t as require_react } from "./react-DA-nE2FX.js";
|
|
8
8
|
import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
|
|
9
|
-
import { ut as outputIsStale } from "./html-to-image-
|
|
9
|
+
import { ut as outputIsStale } from "./html-to-image-Bgb6aaXi.js";
|
|
10
10
|
import "./chunk-5FQGJX7Z-B9WoXK1R.js";
|
|
11
11
|
import { u as createLucideIcon } from "./dist-DgzzIPS4.js";
|
|
12
|
-
import { a as DEFAULT_DECK_TRANSITION, cn as EyeOff, dt as PanelGroup, fn as Code, ft as PanelResizeHandle, l as SlideSidebar, ln as Expand, n as useNotebookCodeAvailable, o as DEFAULT_DECK_VERTICAL_ALIGN, s as DEFAULT_SLIDE_TYPE, t as cellDomProps, u as Slide, ut as Panel } from "./common-
|
|
12
|
+
import { a as DEFAULT_DECK_TRANSITION, cn as EyeOff, dt as PanelGroup, fn as Code, ft as PanelResizeHandle, l as SlideSidebar, ln as Expand, n as useNotebookCodeAvailable, o as DEFAULT_DECK_VERTICAL_ALIGN, s as DEFAULT_SLIDE_TYPE, t as cellDomProps, u as Slide, ut as Panel } from "./common-C2Coc2Bh.js";
|
|
13
13
|
import { R as ChevronDown } from "./strings-CITcoFNY.js";
|
|
14
14
|
import { X as useDebouncedCallback } from "./input-DtTqU0xh.js";
|
|
15
15
|
import { v as kioskModeAtom } from "./toDate-kxcuDOWd.js";
|
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { ChartSchema } from "../schemas";
|
|
5
|
+
|
|
6
|
+
describe("ChartSchema", () => {
|
|
7
|
+
it("keeps a known axis column type", () => {
|
|
8
|
+
const result = ChartSchema.parse({
|
|
9
|
+
general: { xColumn: { field: "count", type: "integer" } },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(result.general?.xColumn?.type).toBe("integer");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("normalizes an unrecognized axis column type", () => {
|
|
16
|
+
const result = ChartSchema.parse({
|
|
17
|
+
general: { xColumn: { field: "geom", type: "geometry" } },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(result.general?.xColumn?.type).toBe("unknown");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("normalizes an unrecognized tooltip field type", () => {
|
|
24
|
+
const result = ChartSchema.parse({
|
|
25
|
+
tooltips: {
|
|
26
|
+
auto: false,
|
|
27
|
+
fields: [{ field: "geom", type: "geometry" }],
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result.tooltips?.fields).toEqual([
|
|
32
|
+
{ field: "geom", type: "unknown" },
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -28,7 +28,10 @@ export const BinSchema = z.object({
|
|
|
28
28
|
|
|
29
29
|
const BaseColumnSchema = z.object({
|
|
30
30
|
field: z.string().optional(),
|
|
31
|
-
type: z
|
|
31
|
+
type: z
|
|
32
|
+
.enum([...DATA_TYPES, EMPTY_VALUE])
|
|
33
|
+
.catch("unknown")
|
|
34
|
+
.optional(),
|
|
32
35
|
selectedDataType: z.enum([...SELECTABLE_DATA_TYPES, EMPTY_VALUE]).optional(),
|
|
33
36
|
sort: z.enum(SORT_TYPES).default("ascending").optional(),
|
|
34
37
|
timeUnit: z.enum(TIME_UNITS).optional(),
|
|
@@ -102,7 +105,7 @@ export const ChartSchema = z.object({
|
|
|
102
105
|
fields: z.array(
|
|
103
106
|
z.object({
|
|
104
107
|
field: z.string(),
|
|
105
|
-
type: z.enum(DATA_TYPES),
|
|
108
|
+
type: z.enum(DATA_TYPES).catch("unknown"),
|
|
106
109
|
}),
|
|
107
110
|
),
|
|
108
111
|
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { DataType } from "@/core/kernel/messages";
|
|
5
|
+
import { DATA_TYPE_ICON, resolveDataType } from "../icons";
|
|
6
|
+
|
|
7
|
+
describe("resolveDataType", () => {
|
|
8
|
+
it("passes through every mapped data type", () => {
|
|
9
|
+
for (const dataType of Object.keys(DATA_TYPE_ICON)) {
|
|
10
|
+
expect(resolveDataType(dataType as DataType)).toBe(dataType);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("resolves unmapped data types to unknown", () => {
|
|
15
|
+
expect(resolveDataType("geometry" as DataType)).toBe("unknown");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("resolves data types shadowing Object.prototype keys to unknown", () => {
|
|
19
|
+
expect(resolveDataType("toString" as DataType)).toBe("unknown");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -30,6 +30,16 @@ export const DATA_TYPE_ICON: Record<DataType | SelectableDataType, LucideIcon> =
|
|
|
30
30
|
unknown: CurlyBracesIcon,
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A newer backend can send a data type this frontend does not know. Resolve
|
|
35
|
+
* those to `unknown` before indexing icon or color maps.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveDataType(
|
|
38
|
+
dataType: DataType | SelectableDataType,
|
|
39
|
+
): DataType | SelectableDataType {
|
|
40
|
+
return Object.hasOwn(DATA_TYPE_ICON, dataType) ? dataType : "unknown";
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
export function getDataTypeColor(
|
|
34
44
|
dataType: DataType | SelectableDataType,
|
|
35
45
|
): string {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { render } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DataType } from "@/core/kernel/messages";
|
|
6
|
+
import { Logger } from "@/utils/Logger";
|
|
7
|
+
import { ColumnName } from "../components";
|
|
8
|
+
|
|
9
|
+
describe("ColumnName", () => {
|
|
10
|
+
it("renders the unknown icon for unrecognized data types", () => {
|
|
11
|
+
const warn = vi.spyOn(Logger, "warn").mockImplementation(() => {});
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const unrecognized = render(
|
|
15
|
+
<ColumnName columnName="geom" dataType={"geometry" as DataType} />,
|
|
16
|
+
);
|
|
17
|
+
const unknown = render(
|
|
18
|
+
<ColumnName columnName="geom" dataType="unknown" />,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(unrecognized.container.innerHTML).toEqual(
|
|
22
|
+
unknown.container.innerHTML,
|
|
23
|
+
);
|
|
24
|
+
expect(warn).not.toHaveBeenCalled();
|
|
25
|
+
} finally {
|
|
26
|
+
warn.mockRestore();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -5,7 +5,11 @@ import type { CSSProperties } from "react";
|
|
|
5
5
|
import { TreeChevron } from "@/components/editor/file-tree/tree-actions";
|
|
6
6
|
import type { DataType } from "@/core/kernel/messages";
|
|
7
7
|
import { cn } from "@/utils/cn";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
DATA_TYPE_ICON,
|
|
10
|
+
getDataTypeColor,
|
|
11
|
+
resolveDataType,
|
|
12
|
+
} from "../datasets/icons";
|
|
9
13
|
|
|
10
14
|
export const RotatingChevron: React.FC<{ isExpanded: boolean }> = ({
|
|
11
15
|
isExpanded,
|
|
@@ -101,8 +105,9 @@ export const ColumnName = ({
|
|
|
101
105
|
columnName: React.ReactNode;
|
|
102
106
|
dataType: DataType;
|
|
103
107
|
}) => {
|
|
104
|
-
const
|
|
105
|
-
const
|
|
108
|
+
const resolvedDataType = resolveDataType(dataType);
|
|
109
|
+
const Icon = DATA_TYPE_ICON[resolvedDataType];
|
|
110
|
+
const color = getDataTypeColor(resolvedDataType);
|
|
106
111
|
|
|
107
112
|
return (
|
|
108
113
|
<div className="flex flex-row items-center gap-1.5">
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
MARKDOWN_EXTENSIONS,
|
|
5
|
+
MARKDOWN_SUFFIXES,
|
|
6
|
+
} from "@/core/network/markdown-export";
|
|
3
7
|
import { Filenames } from "@/utils/filenames";
|
|
4
8
|
import { shellQuote } from "@/utils/shell";
|
|
5
9
|
import type { ExportFormat, ExportOptions, MarkdownFlavor } from "./state";
|
|
6
10
|
|
|
7
|
-
const MARKDOWN_EXTENSIONS: Record<MarkdownFlavor, string> = {
|
|
8
|
-
pymdown: "md",
|
|
9
|
-
qmd: "qmd",
|
|
10
|
-
mystmd: "myst.md",
|
|
11
|
-
mdx: "mdx",
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const MARKDOWN_SUFFIXES = [".myst.md", ".markdown", ".qmd", ".mdx", ".md"];
|
|
15
|
-
|
|
16
11
|
function inferMarkdownFlavor(filename: string): MarkdownFlavor {
|
|
17
12
|
if (filename.endsWith(".myst.md")) {
|
|
18
13
|
return "mystmd";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { render } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import type {
|
|
6
|
+
DataTable,
|
|
7
|
+
DataTableColumn,
|
|
8
|
+
DataType,
|
|
9
|
+
} from "@/core/kernel/messages";
|
|
10
|
+
import { renderColumnInfo, renderTableInfo } from "../renderers";
|
|
11
|
+
|
|
12
|
+
const geometryColumn = {
|
|
13
|
+
name: "geom",
|
|
14
|
+
type: "geometry" as DataType,
|
|
15
|
+
external_type: "geometry",
|
|
16
|
+
sample_values: [],
|
|
17
|
+
} as DataTableColumn;
|
|
18
|
+
|
|
19
|
+
describe("SQL renderers", () => {
|
|
20
|
+
it("renders table information with an unrecognized column type", () => {
|
|
21
|
+
const table: DataTable = {
|
|
22
|
+
name: "shapes",
|
|
23
|
+
type: "table",
|
|
24
|
+
source: "local",
|
|
25
|
+
source_type: "local",
|
|
26
|
+
num_rows: null,
|
|
27
|
+
num_columns: 1,
|
|
28
|
+
variable_name: null,
|
|
29
|
+
columns: [geometryColumn],
|
|
30
|
+
primary_keys: [],
|
|
31
|
+
indexes: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const result = render(renderTableInfo(table));
|
|
35
|
+
|
|
36
|
+
expect(result.container.querySelector("svg")).not.toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders column information with an unrecognized column type", () => {
|
|
40
|
+
const result = render(renderColumnInfo(geometryColumn));
|
|
41
|
+
|
|
42
|
+
expect(result.container.querySelector("svg")).not.toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
TableIcon,
|
|
14
14
|
ViewIcon,
|
|
15
15
|
} from "@/components/databases/namespace-icons";
|
|
16
|
-
import { DATA_TYPE_ICON } from "@/components/datasets/icons";
|
|
16
|
+
import { DATA_TYPE_ICON, resolveDataType } from "@/components/datasets/icons";
|
|
17
17
|
import { Badge } from "@/components/ui/badge";
|
|
18
18
|
import {
|
|
19
19
|
type ConnectionName,
|
|
@@ -149,7 +149,7 @@ export const renderTableInfo = (table: DataTable): React.ReactNode => {
|
|
|
149
149
|
);
|
|
150
150
|
|
|
151
151
|
const columnItems = table.columns.map((column) => {
|
|
152
|
-
const TypeIcon = DATA_TYPE_ICON[column.type];
|
|
152
|
+
const TypeIcon = DATA_TYPE_ICON[resolveDataType(column.type)];
|
|
153
153
|
return (
|
|
154
154
|
<div
|
|
155
155
|
key={column.name}
|
|
@@ -288,7 +288,7 @@ export const renderTableInfo = (table: DataTable): React.ReactNode => {
|
|
|
288
288
|
};
|
|
289
289
|
|
|
290
290
|
export const renderColumnInfo = (column: DataTableColumn): React.ReactNode => {
|
|
291
|
-
const TypeIcon = DATA_TYPE_ICON[column.type];
|
|
291
|
+
const TypeIcon = DATA_TYPE_ICON[resolveDataType(column.type)];
|
|
292
292
|
|
|
293
293
|
const typeBadge = (
|
|
294
294
|
<Badge
|
|
@@ -105,7 +105,10 @@ describe("API", () => {
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
await expect(
|
|
108
|
-
API.handleExportResponse(
|
|
108
|
+
API.handleExportResponse(
|
|
109
|
+
{ data: "markdown", response },
|
|
110
|
+
{ defaultFilename: "download.md" },
|
|
111
|
+
),
|
|
109
112
|
).resolves.toEqual({
|
|
110
113
|
contents: "markdown",
|
|
111
114
|
filename: "résumé.qmd",
|
|
@@ -113,27 +116,139 @@ describe("API", () => {
|
|
|
113
116
|
});
|
|
114
117
|
});
|
|
115
118
|
|
|
116
|
-
it("
|
|
119
|
+
it("parses quoted Content-Disposition filenames", async () => {
|
|
120
|
+
const response = new Response("html", {
|
|
121
|
+
headers: {
|
|
122
|
+
"Content-Disposition": 'inline; filename="notebook.html"',
|
|
123
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
117
127
|
await expect(
|
|
118
|
-
API.handleExportResponse(
|
|
119
|
-
data: "
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
API.handleExportResponse(
|
|
129
|
+
{ data: "html", response },
|
|
130
|
+
{ defaultFilename: "download.html" },
|
|
131
|
+
),
|
|
132
|
+
).resolves.toEqual({
|
|
133
|
+
contents: "html",
|
|
134
|
+
filename: "notebook.html",
|
|
135
|
+
mediaType: "text/html; charset=utf-8",
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("parses unquoted Content-Disposition filenames", async () => {
|
|
140
|
+
const response = new Response("pdf", {
|
|
141
|
+
headers: {
|
|
142
|
+
"Content-Disposition": "attachment; filename=notebook.pdf",
|
|
143
|
+
"Content-Type": "application/pdf",
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await expect(
|
|
148
|
+
API.handleExportResponse(
|
|
149
|
+
{ data: "pdf", response },
|
|
150
|
+
{ defaultFilename: "download.pdf" },
|
|
151
|
+
),
|
|
152
|
+
).resolves.toEqual({
|
|
153
|
+
contents: "pdf",
|
|
154
|
+
filename: "notebook.pdf",
|
|
155
|
+
mediaType: "application/pdf",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("prefers RFC 5987 filenames over quoted fallbacks", async () => {
|
|
160
|
+
const response = new Response("markdown", {
|
|
161
|
+
headers: {
|
|
162
|
+
"Content-Disposition":
|
|
163
|
+
"attachment; filename=\"wrong.md\"; filename*=UTF-8''notebook.md",
|
|
164
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
API.handleExportResponse(
|
|
170
|
+
{ data: "markdown", response },
|
|
171
|
+
{ defaultFilename: "download.md" },
|
|
172
|
+
),
|
|
173
|
+
).resolves.toEqual({
|
|
174
|
+
contents: "markdown",
|
|
175
|
+
filename: "notebook.md",
|
|
176
|
+
mediaType: "text/plain; charset=utf-8",
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("falls back to a default filename when headers are missing", async () => {
|
|
181
|
+
const response = new Response("markdown", {
|
|
182
|
+
headers: {
|
|
183
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await expect(
|
|
188
|
+
API.handleExportResponse(
|
|
189
|
+
{ data: "markdown", response },
|
|
190
|
+
{ defaultFilename: "download.md" },
|
|
191
|
+
),
|
|
192
|
+
).resolves.toEqual({
|
|
193
|
+
contents: "markdown",
|
|
194
|
+
filename: "download.md",
|
|
195
|
+
mediaType: "text/plain; charset=utf-8",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("falls back to a default filename when Content-Disposition is unparsable", async () => {
|
|
200
|
+
const response = new Response("markdown", {
|
|
201
|
+
headers: {
|
|
202
|
+
"Content-Disposition": "attachment",
|
|
203
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await expect(
|
|
208
|
+
API.handleExportResponse(
|
|
209
|
+
{ data: "markdown", response },
|
|
210
|
+
{ defaultFilename: "download.md" },
|
|
211
|
+
),
|
|
212
|
+
).resolves.toEqual({
|
|
213
|
+
contents: "markdown",
|
|
214
|
+
filename: "download.md",
|
|
215
|
+
mediaType: "text/plain; charset=utf-8",
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("falls back to a default filename when RFC 5987 encoding is malformed", async () => {
|
|
220
|
+
const response = new Response("markdown", {
|
|
221
|
+
headers: {
|
|
222
|
+
"Content-Disposition": "attachment; filename*=UTF-8''%E0%A4%A",
|
|
223
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await expect(
|
|
228
|
+
API.handleExportResponse(
|
|
229
|
+
{ data: "markdown", response },
|
|
230
|
+
{ defaultFilename: "download.md" },
|
|
231
|
+
),
|
|
232
|
+
).resolves.toEqual({
|
|
233
|
+
contents: "markdown",
|
|
234
|
+
filename: "download.md",
|
|
235
|
+
mediaType: "text/plain; charset=utf-8",
|
|
236
|
+
});
|
|
125
237
|
});
|
|
126
238
|
|
|
127
239
|
it("rejects export responses without a media type", async () => {
|
|
128
240
|
await expect(
|
|
129
|
-
API.handleExportResponse(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
241
|
+
API.handleExportResponse(
|
|
242
|
+
{
|
|
243
|
+
data: "markdown",
|
|
244
|
+
response: new Response(null, {
|
|
245
|
+
headers: {
|
|
246
|
+
"Content-Disposition": "attachment; filename*=UTF-8''notebook.md",
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
},
|
|
250
|
+
{ defaultFilename: "download.md" },
|
|
251
|
+
),
|
|
137
252
|
).rejects.toThrow("Export response is missing a media type");
|
|
138
253
|
});
|
|
139
254
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { filenameAtom } from "@/core/saving/file-state";
|
|
5
|
+
import { store } from "@/core/state/jotai";
|
|
6
|
+
import {
|
|
7
|
+
getDefaultExportFilename,
|
|
8
|
+
getDefaultMarkdownExportFilename,
|
|
9
|
+
} from "../export-filename";
|
|
10
|
+
|
|
11
|
+
describe("default export filenames", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
store.set(filenameAtom, "folder/my_notebook.py");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("derives filenames from the notebook name", () => {
|
|
17
|
+
expect(getDefaultExportFilename("html")).toBe("my_notebook.html");
|
|
18
|
+
expect(getDefaultExportFilename("pdf")).toBe("my_notebook.pdf");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("handles Windows-style notebook paths", () => {
|
|
22
|
+
store.set(filenameAtom, "C:\\Users\\me\\my_notebook.py");
|
|
23
|
+
expect(getDefaultExportFilename("html")).toBe("my_notebook.html");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("derives markdown filenames from the selected flavor", () => {
|
|
27
|
+
expect(getDefaultMarkdownExportFilename("qmd")).toBe("my_notebook.qmd");
|
|
28
|
+
expect(getDefaultMarkdownExportFilename("mystmd")).toBe(
|
|
29
|
+
"my_notebook.myst.md",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("derives script export filenames from the notebook name", () => {
|
|
34
|
+
expect(getDefaultExportFilename("script.py")).toBe("my_notebook.script.py");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("falls back to download.* when the notebook is unnamed", () => {
|
|
38
|
+
store.set(filenameAtom, null);
|
|
39
|
+
expect(getDefaultExportFilename("html")).toBe("download.html");
|
|
40
|
+
expect(getDefaultMarkdownExportFilename("qmd")).toBe("download.qmd");
|
|
41
|
+
expect(getDefaultExportFilename("script.py")).toBe("download.script.py");
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/core/network/api.ts
CHANGED
|
@@ -16,13 +16,45 @@ function getBaseUriWithoutQueryParams(): string {
|
|
|
16
16
|
return url.toString();
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (!
|
|
23
|
-
|
|
19
|
+
function parseContentDispositionFilename(
|
|
20
|
+
contentDisposition: string | null,
|
|
21
|
+
): string | undefined {
|
|
22
|
+
if (!contentDisposition) {
|
|
23
|
+
return undefined;
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
|
|
26
|
+
const rfc5987Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
|
|
27
|
+
if (rfc5987Match) {
|
|
28
|
+
try {
|
|
29
|
+
return decodeURIComponent(rfc5987Match[1]);
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const quotedMatch = contentDisposition.match(/filename="([^"]+)"/i);
|
|
36
|
+
if (quotedMatch) {
|
|
37
|
+
return quotedMatch[1];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const unquotedMatch = contentDisposition.match(/filename=([^;]+)/i);
|
|
41
|
+
if (unquotedMatch) {
|
|
42
|
+
return unquotedMatch[1].trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getExportFilename(response: Response): string | undefined {
|
|
49
|
+
const filename = parseContentDispositionFilename(
|
|
50
|
+
response.headers.get("Content-Disposition"),
|
|
51
|
+
);
|
|
52
|
+
if (!filename) {
|
|
53
|
+
Logger.warn(
|
|
54
|
+
"Export response is missing a readable Content-Disposition filename",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return filename;
|
|
26
58
|
}
|
|
27
59
|
|
|
28
60
|
/**
|
|
@@ -129,11 +161,14 @@ export const API = {
|
|
|
129
161
|
}
|
|
130
162
|
return Promise.resolve(response.data as T);
|
|
131
163
|
},
|
|
132
|
-
handleExportResponse: async <T extends BlobPart>(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
164
|
+
handleExportResponse: async <T extends BlobPart>(
|
|
165
|
+
response: {
|
|
166
|
+
data?: T | undefined;
|
|
167
|
+
error?: Record<string, unknown>;
|
|
168
|
+
response: Response;
|
|
169
|
+
},
|
|
170
|
+
opts: { defaultFilename: string },
|
|
171
|
+
): Promise<ExportedFile<T>> => {
|
|
137
172
|
const contents = await API.handleResponse<T>(response);
|
|
138
173
|
const mediaType = response.response.headers.get("Content-Type");
|
|
139
174
|
if (!mediaType) {
|
|
@@ -142,7 +177,7 @@ export const API = {
|
|
|
142
177
|
|
|
143
178
|
return {
|
|
144
179
|
contents,
|
|
145
|
-
filename: getExportFilename(response.response),
|
|
180
|
+
filename: getExportFilename(response.response) ?? opts.defaultFilename,
|
|
146
181
|
mediaType,
|
|
147
182
|
};
|
|
148
183
|
},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { filenameAtom } from "@/core/saving/file-state";
|
|
4
|
+
import { store } from "@/core/state/jotai";
|
|
5
|
+
import { Filenames } from "@/utils/filenames";
|
|
6
|
+
import { Paths } from "@/utils/paths";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_MARKDOWN_FLAVOR,
|
|
9
|
+
MARKDOWN_EXTENSIONS,
|
|
10
|
+
} from "./markdown-export";
|
|
11
|
+
import type { ExportAsMarkdownRequest } from "./types";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_EXPORT_FILENAME = "download";
|
|
14
|
+
|
|
15
|
+
function getNotebookStem(): string | undefined {
|
|
16
|
+
const filename = store.get(filenameAtom);
|
|
17
|
+
if (!filename) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const basename = Paths.basename(filename);
|
|
21
|
+
return Filenames.withoutExtension(basename);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDefaultExportFilename(extension: string): string {
|
|
25
|
+
const stem = getNotebookStem();
|
|
26
|
+
if (stem) {
|
|
27
|
+
return `${stem}.${extension}`;
|
|
28
|
+
}
|
|
29
|
+
return `${DEFAULT_EXPORT_FILENAME}.${extension}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getDefaultMarkdownExportFilename(
|
|
33
|
+
flavor: ExportAsMarkdownRequest["flavor"],
|
|
34
|
+
): string {
|
|
35
|
+
const extension = MARKDOWN_EXTENSIONS[flavor ?? DEFAULT_MARKDOWN_FLAVOR];
|
|
36
|
+
return getDefaultExportFilename(extension);
|
|
37
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { MarkdownExportFlavor } from "./types";
|
|
4
|
+
|
|
5
|
+
export const MARKDOWN_EXTENSIONS = {
|
|
6
|
+
pymdown: "md",
|
|
7
|
+
qmd: "qmd",
|
|
8
|
+
mystmd: "myst.md",
|
|
9
|
+
mdx: "mdx",
|
|
10
|
+
} satisfies Record<MarkdownExportFlavor, string>;
|
|
11
|
+
export const MARKDOWN_SUFFIXES = [
|
|
12
|
+
".myst.md",
|
|
13
|
+
".markdown",
|
|
14
|
+
".qmd",
|
|
15
|
+
".mdx",
|
|
16
|
+
".md",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_MARKDOWN_FLAVOR: MarkdownExportFlavor = "pymdown";
|