@marimo-team/islands 0.19.7-dev33 → 0.19.7-dev34
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 +89 -14
- package/package.json +1 -1
- package/src/components/editor/Output.tsx +6 -10
- package/src/utils/__tests__/mime-types.test.ts +326 -0
- package/src/utils/mime-types.ts +181 -0
package/dist/main.js
CHANGED
|
@@ -34867,7 +34867,7 @@ ${c.sqlString}
|
|
|
34867
34867
|
let e2 = r === "validate" ? "default" : "validate";
|
|
34868
34868
|
e2 === "default" && clearAllSqlValidationErrors(), c(e2);
|
|
34869
34869
|
}, e[0] = c, e[1] = r, e[2] = d) : d = e[2];
|
|
34870
|
-
let f = d, _ = _temp4$
|
|
34870
|
+
let f = d, _ = _temp4$11, v = _temp5$8, y;
|
|
34871
34871
|
e[3] === r ? y = e[4] : (y = v(r), e[3] = r, e[4] = y);
|
|
34872
34872
|
let S;
|
|
34873
34873
|
e[5] === r ? S = e[6] : (S = _(r), e[5] = r, e[6] = S);
|
|
@@ -34931,7 +34931,7 @@ ${c.sqlString}
|
|
|
34931
34931
|
function _temp3$20(e) {
|
|
34932
34932
|
return e = e.filter(_temp$39), e.map(_temp2$27);
|
|
34933
34933
|
}
|
|
34934
|
-
function _temp4$
|
|
34934
|
+
function _temp4$11(e) {
|
|
34935
34935
|
return e === "validate" ? (0, import_jsx_runtime.jsx)(SearchCheck, {
|
|
34936
34936
|
className: "h-3 w-3"
|
|
34937
34937
|
}) : (0, import_jsx_runtime.jsx)(DatabaseBackup, {
|
|
@@ -51487,7 +51487,7 @@ Database schema: ${c}`), (_a3 = r2.aiFix) == null ? void 0 : _a3.setAiCompletion
|
|
|
51487
51487
|
if (c.some(_temp$36)) v = "Interrupted";
|
|
51488
51488
|
else if (c.some(_temp2$25)) v = "An internal error occurred";
|
|
51489
51489
|
else if (c.some(_temp3$18)) v = "Ancestor prevented from running", y = "default", S = "text-secondary-foreground";
|
|
51490
|
-
else if (c.some(_temp4$
|
|
51490
|
+
else if (c.some(_temp4$10)) v = "Ancestor stopped", y = "default", S = "text-secondary-foreground";
|
|
51491
51491
|
else if (c.some(_temp5$7)) v = "SQL error";
|
|
51492
51492
|
else {
|
|
51493
51493
|
let e2;
|
|
@@ -51860,7 +51860,7 @@ Database schema: ${c}`), (_a3 = r2.aiFix) == null ? void 0 : _a3.setAiCompletion
|
|
|
51860
51860
|
function _temp3$18(e) {
|
|
51861
51861
|
return e.type === "ancestor-prevented";
|
|
51862
51862
|
}
|
|
51863
|
-
function _temp4$
|
|
51863
|
+
function _temp4$10(e) {
|
|
51864
51864
|
return e.type === "ancestor-stopped";
|
|
51865
51865
|
}
|
|
51866
51866
|
function _temp5$7(e) {
|
|
@@ -52191,6 +52191,85 @@ Database schema: ${c}`), (_a3 = r2.aiFix) == null ? void 0 : _a3.setAiCompletion
|
|
|
52191
52191
|
function isOutputEmpty(e) {
|
|
52192
52192
|
return e == null || e.data == null || e.data === "";
|
|
52193
52193
|
}
|
|
52194
|
+
function createMimeConfig(e) {
|
|
52195
|
+
let r = /* @__PURE__ */ new Map();
|
|
52196
|
+
for (let c2 = 0; c2 < e.precedence.length; c2++) r.set(e.precedence[c2], c2);
|
|
52197
|
+
let c = /* @__PURE__ */ new Map();
|
|
52198
|
+
for (let [r2, d] of Object.entries(e.hidingRules)) c.set(r2, new Set(d));
|
|
52199
|
+
return {
|
|
52200
|
+
precedence: r,
|
|
52201
|
+
hidingRules: c
|
|
52202
|
+
};
|
|
52203
|
+
}
|
|
52204
|
+
const getDefaultMimeConfig = once(() => {
|
|
52205
|
+
let e = [
|
|
52206
|
+
"image/png",
|
|
52207
|
+
"image/jpeg",
|
|
52208
|
+
"image/gif"
|
|
52209
|
+
];
|
|
52210
|
+
return createMimeConfig({
|
|
52211
|
+
precedence: [
|
|
52212
|
+
"text/html",
|
|
52213
|
+
"application/vnd.vegalite.v6+json",
|
|
52214
|
+
"application/vnd.vegalite.v5+json",
|
|
52215
|
+
"application/vnd.vega.v6+json",
|
|
52216
|
+
"application/vnd.vega.v5+json",
|
|
52217
|
+
"image/svg+xml",
|
|
52218
|
+
"image/png",
|
|
52219
|
+
"image/jpeg",
|
|
52220
|
+
"image/gif",
|
|
52221
|
+
"text/markdown",
|
|
52222
|
+
"text/latex",
|
|
52223
|
+
"text/csv",
|
|
52224
|
+
"application/json",
|
|
52225
|
+
"text/plain",
|
|
52226
|
+
"video/mp4",
|
|
52227
|
+
"video/mpeg"
|
|
52228
|
+
],
|
|
52229
|
+
hidingRules: {
|
|
52230
|
+
"text/html": [
|
|
52231
|
+
...e,
|
|
52232
|
+
"image/avif",
|
|
52233
|
+
"image/bmp",
|
|
52234
|
+
"image/tiff"
|
|
52235
|
+
],
|
|
52236
|
+
"application/vnd.vegalite.v6+json": e,
|
|
52237
|
+
"application/vnd.vegalite.v5+json": e,
|
|
52238
|
+
"application/vnd.vega.v6+json": e,
|
|
52239
|
+
"application/vnd.vega.v5+json": e
|
|
52240
|
+
}
|
|
52241
|
+
});
|
|
52242
|
+
});
|
|
52243
|
+
function applyHidingRules(e, r) {
|
|
52244
|
+
let c = /* @__PURE__ */ new Set();
|
|
52245
|
+
for (let d2 of e) {
|
|
52246
|
+
let f = r.get(d2);
|
|
52247
|
+
if (f) for (let r2 of f) e.has(r2) && c.add(r2);
|
|
52248
|
+
}
|
|
52249
|
+
let d = /* @__PURE__ */ new Set();
|
|
52250
|
+
for (let r2 of e) c.has(r2) || d.add(r2);
|
|
52251
|
+
return {
|
|
52252
|
+
visible: d,
|
|
52253
|
+
hidden: c
|
|
52254
|
+
};
|
|
52255
|
+
}
|
|
52256
|
+
function sortByPrecedence(e, r) {
|
|
52257
|
+
let c = r.size;
|
|
52258
|
+
return [
|
|
52259
|
+
...e
|
|
52260
|
+
].sort((e2, d) => (r.get(e2[0]) ?? c) - (r.get(d[0]) ?? c));
|
|
52261
|
+
}
|
|
52262
|
+
function processMimeBundle(e, r = getDefaultMimeConfig()) {
|
|
52263
|
+
if (e.length === 0) return {
|
|
52264
|
+
entries: [],
|
|
52265
|
+
hidden: []
|
|
52266
|
+
};
|
|
52267
|
+
let { visible: c, hidden: d } = applyHidingRules(new Set(e.map(([e2]) => e2)), r.hidingRules);
|
|
52268
|
+
return {
|
|
52269
|
+
entries: sortByPrecedence(e.filter(([e2]) => c.has(e2)), r.precedence),
|
|
52270
|
+
hidden: Array.from(d)
|
|
52271
|
+
};
|
|
52272
|
+
}
|
|
52194
52273
|
const LazyVegaEmbed = import_react.lazy(() => import("./react-vega-DgHpnZ04.js").then((e) => ({
|
|
52195
52274
|
default: e.VegaEmbed
|
|
52196
52275
|
})));
|
|
@@ -62832,7 +62911,7 @@ ${O}`,
|
|
|
62832
62911
|
if (r[0] !== y || r[1] !== f || r[2] !== d || r[3] !== S || r[4] !== _) {
|
|
62833
62912
|
q = /* @__PURE__ */ Symbol.for("react.early_return_sentinel");
|
|
62834
62913
|
bb0: {
|
|
62835
|
-
let e2 = Objects.entries(_).filter(_temp$33).map(_temp2$24), c2 = (_a2 = e2[0]) == null ? void 0 : _a2[0];
|
|
62914
|
+
let { entries: e2 } = processMimeBundle(Objects.entries(_).filter(_temp$33).map(_temp2$24)), c2 = (_a2 = e2[0]) == null ? void 0 : _a2[0];
|
|
62836
62915
|
if (!c2) {
|
|
62837
62916
|
q = null;
|
|
62838
62917
|
break bb0;
|
|
@@ -62842,18 +62921,18 @@ ${O}`,
|
|
|
62842
62921
|
cellId: f,
|
|
62843
62922
|
message: {
|
|
62844
62923
|
channel: d,
|
|
62845
|
-
data:
|
|
62924
|
+
data: e2[0][1],
|
|
62846
62925
|
mimetype: c2
|
|
62847
62926
|
},
|
|
62848
62927
|
metadata: S == null ? void 0 : S[c2]
|
|
62849
62928
|
});
|
|
62850
62929
|
break bb0;
|
|
62851
62930
|
}
|
|
62852
|
-
|
|
62931
|
+
w = Tabs, z = c2, G = "vertical", M = "flex";
|
|
62853
62932
|
let v2 = y && "mt-4", IY2;
|
|
62854
62933
|
r[13] === v2 ? IY2 = r[14] : (IY2 = cn("self-start max-h-none flex flex-col gap-2 mr-3 shrink-0", v2), r[13] = v2, r[14] = IY2), I = (0, import_jsx_runtime.jsx)(TabsList, {
|
|
62855
62934
|
className: IY2,
|
|
62856
|
-
children: e2.map(
|
|
62935
|
+
children: e2.map(_temp3$17)
|
|
62857
62936
|
}), E = "flex-1 w-full";
|
|
62858
62937
|
let LY2;
|
|
62859
62938
|
r[15] !== f || r[16] !== d || r[17] !== S ? (LY2 = (e3) => {
|
|
@@ -63040,11 +63119,7 @@ ${O}`,
|
|
|
63040
63119
|
c
|
|
63041
63120
|
];
|
|
63042
63121
|
}
|
|
63043
|
-
function _temp3$17(e
|
|
63044
|
-
let [c] = e;
|
|
63045
|
-
return c === "text/html" ? -1 : 0;
|
|
63046
|
-
}
|
|
63047
|
-
function _temp4$10(e) {
|
|
63122
|
+
function _temp3$17(e) {
|
|
63048
63123
|
let [r] = e;
|
|
63049
63124
|
return (0, import_jsx_runtime.jsx)(TabsTrigger, {
|
|
63050
63125
|
value: r,
|
|
@@ -73083,7 +73158,7 @@ Image URL: ${r.imageUrl}`)), contextToXml({
|
|
|
73083
73158
|
return Logger.warn("Failed to get version from mount config"), null;
|
|
73084
73159
|
}
|
|
73085
73160
|
}
|
|
73086
|
-
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.7-
|
|
73161
|
+
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.7-dev34"), showCodeInRunModeAtom = atom(true);
|
|
73087
73162
|
atom(null);
|
|
73088
73163
|
var import_compiler_runtime$88 = require_compiler_runtime();
|
|
73089
73164
|
function useKeydownOnElement(e, r) {
|
package/package.json
CHANGED
|
@@ -36,6 +36,7 @@ import type { TopLevelFacetedUnitSpec } from "@/plugins/impl/data-explorer/queri
|
|
|
36
36
|
import { useTheme } from "@/theme/useTheme";
|
|
37
37
|
import { Events } from "@/utils/events";
|
|
38
38
|
import { invariant } from "@/utils/invariant";
|
|
39
|
+
import { processMimeBundle } from "@/utils/mime-types";
|
|
39
40
|
import { Objects } from "@/utils/objects";
|
|
40
41
|
import { LazyVegaEmbed } from "../charts/lazy";
|
|
41
42
|
import { ChartLoadingState } from "../data-table/charts/components/chart-states";
|
|
@@ -268,10 +269,13 @@ const MimeBundleOutputRenderer: React.FC<{
|
|
|
268
269
|
const metadata = mimebundle[METADATA_KEY];
|
|
269
270
|
|
|
270
271
|
// Filter out metadata from the mime entries and type narrow
|
|
271
|
-
const
|
|
272
|
+
const rawEntries = Objects.entries(mimebundle as Record<string, unknown>)
|
|
272
273
|
.filter(([key]) => key !== METADATA_KEY)
|
|
273
274
|
.map(([mime, data]) => [mime, data] as [MimeType, CellOutput["data"]]);
|
|
274
275
|
|
|
276
|
+
// Apply precedence ordering and hiding rules
|
|
277
|
+
const { entries: mimeEntries } = processMimeBundle(rawEntries);
|
|
278
|
+
|
|
275
279
|
// If there is none, return null
|
|
276
280
|
const first = mimeEntries[0]?.[0];
|
|
277
281
|
if (!first) {
|
|
@@ -285,7 +289,7 @@ const MimeBundleOutputRenderer: React.FC<{
|
|
|
285
289
|
cellId={cellId}
|
|
286
290
|
message={{
|
|
287
291
|
channel: channel,
|
|
288
|
-
data:
|
|
292
|
+
data: mimeEntries[0][1],
|
|
289
293
|
mimetype: first,
|
|
290
294
|
}}
|
|
291
295
|
metadata={metadata?.[first]}
|
|
@@ -293,14 +297,6 @@ const MimeBundleOutputRenderer: React.FC<{
|
|
|
293
297
|
);
|
|
294
298
|
}
|
|
295
299
|
|
|
296
|
-
// Sort HTML first
|
|
297
|
-
mimeEntries.sort(([mimeA], [_mimeB]) => {
|
|
298
|
-
if (mimeA === "text/html") {
|
|
299
|
-
return -1;
|
|
300
|
-
}
|
|
301
|
-
return 0;
|
|
302
|
-
});
|
|
303
|
-
|
|
304
300
|
return (
|
|
305
301
|
<Tabs defaultValue={first} orientation="vertical">
|
|
306
302
|
<div className="flex">
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { MimeType } from "@/components/editor/Output";
|
|
4
|
+
import {
|
|
5
|
+
applyHidingRules,
|
|
6
|
+
createMimeConfig,
|
|
7
|
+
getDefaultMimeConfig,
|
|
8
|
+
processMimeBundle,
|
|
9
|
+
sortByPrecedence,
|
|
10
|
+
} from "../mime-types";
|
|
11
|
+
|
|
12
|
+
/** Helper to build a hiding rules Map inline */
|
|
13
|
+
function hidingRules(
|
|
14
|
+
rules: Record<string, MimeType[]>,
|
|
15
|
+
): ReadonlyMap<MimeType, ReadonlySet<MimeType>> {
|
|
16
|
+
const map = new Map<MimeType, ReadonlySet<MimeType>>();
|
|
17
|
+
for (const [trigger, toHide] of Object.entries(rules)) {
|
|
18
|
+
map.set(trigger as MimeType, new Set(toHide));
|
|
19
|
+
}
|
|
20
|
+
return map;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Helper to build a precedence Map inline */
|
|
24
|
+
function precedenceMap(types: MimeType[]): ReadonlyMap<MimeType, number> {
|
|
25
|
+
const map = new Map<MimeType, number>();
|
|
26
|
+
types.forEach((mime, i) => map.set(mime, i));
|
|
27
|
+
return map;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("mime-types", () => {
|
|
31
|
+
describe("applyHidingRules", () => {
|
|
32
|
+
it("should return all visible when no rules match", () => {
|
|
33
|
+
const mimeTypes = new Set<MimeType>(["text/plain", "text/markdown"]);
|
|
34
|
+
const rules = hidingRules({ "text/html": ["image/png"] });
|
|
35
|
+
|
|
36
|
+
const result = applyHidingRules(mimeTypes, rules);
|
|
37
|
+
|
|
38
|
+
expect(result.visible).toEqual(new Set(["text/plain", "text/markdown"]));
|
|
39
|
+
expect(result.hidden.size).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should hide mime types when trigger is present", () => {
|
|
43
|
+
const mimeTypes = new Set<MimeType>([
|
|
44
|
+
"text/html",
|
|
45
|
+
"image/png",
|
|
46
|
+
"text/plain",
|
|
47
|
+
]);
|
|
48
|
+
const rules = hidingRules({ "text/html": ["image/png"] });
|
|
49
|
+
|
|
50
|
+
const result = applyHidingRules(mimeTypes, rules);
|
|
51
|
+
|
|
52
|
+
expect(result.visible).toEqual(new Set(["text/html", "text/plain"]));
|
|
53
|
+
expect(result.hidden).toEqual(new Set(["image/png"]));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should not hide markdown when html is present (per requirements)", () => {
|
|
57
|
+
const mimeTypes = new Set<MimeType>([
|
|
58
|
+
"text/html",
|
|
59
|
+
"text/markdown",
|
|
60
|
+
"image/png",
|
|
61
|
+
]);
|
|
62
|
+
const rules = hidingRules({ "text/html": ["image/png"] });
|
|
63
|
+
|
|
64
|
+
const result = applyHidingRules(mimeTypes, rules);
|
|
65
|
+
|
|
66
|
+
expect(result.visible.has("text/markdown")).toBe(true);
|
|
67
|
+
expect(result.visible.has("text/html")).toBe(true);
|
|
68
|
+
expect(result.hidden.has("image/png")).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should handle multiple matching rules", () => {
|
|
72
|
+
const mimeTypes = new Set<MimeType>([
|
|
73
|
+
"text/html",
|
|
74
|
+
"application/vnd.vegalite.v5+json",
|
|
75
|
+
"image/png",
|
|
76
|
+
"image/jpeg",
|
|
77
|
+
]);
|
|
78
|
+
const rules = hidingRules({
|
|
79
|
+
"text/html": ["image/png"],
|
|
80
|
+
"application/vnd.vegalite.v5+json": ["image/jpeg"],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const result = applyHidingRules(mimeTypes, rules);
|
|
84
|
+
|
|
85
|
+
expect(result.hidden).toEqual(new Set(["image/png", "image/jpeg"]));
|
|
86
|
+
expect(result.visible).toEqual(
|
|
87
|
+
new Set(["text/html", "application/vnd.vegalite.v5+json"]),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should handle empty mime types", () => {
|
|
92
|
+
const mimeTypes = new Set<MimeType>();
|
|
93
|
+
const rules = hidingRules({ "text/html": ["image/png"] });
|
|
94
|
+
|
|
95
|
+
const result = applyHidingRules(mimeTypes, rules);
|
|
96
|
+
|
|
97
|
+
expect(result.visible.size).toBe(0);
|
|
98
|
+
expect(result.hidden.size).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should handle empty rules", () => {
|
|
102
|
+
const mimeTypes = new Set<MimeType>(["text/html", "image/png"]);
|
|
103
|
+
const rules = hidingRules({});
|
|
104
|
+
|
|
105
|
+
const result = applyHidingRules(mimeTypes, rules);
|
|
106
|
+
|
|
107
|
+
expect(result.visible).toEqual(mimeTypes);
|
|
108
|
+
expect(result.hidden.size).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should hide a type that is also a trigger if configured", () => {
|
|
112
|
+
const mimeTypes = new Set<MimeType>(["text/html", "text/plain"]);
|
|
113
|
+
const rules = hidingRules({ "text/html": ["text/html"] });
|
|
114
|
+
|
|
115
|
+
const result = applyHidingRules(mimeTypes, rules);
|
|
116
|
+
|
|
117
|
+
expect(result.hidden.has("text/html")).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should not hide types that are not present", () => {
|
|
121
|
+
const mimeTypes = new Set<MimeType>(["text/html"]);
|
|
122
|
+
const rules = hidingRules({
|
|
123
|
+
"text/html": ["image/png", "image/jpeg"],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = applyHidingRules(mimeTypes, rules);
|
|
127
|
+
|
|
128
|
+
expect(result.hidden.size).toBe(0);
|
|
129
|
+
expect(result.visible).toEqual(new Set(["text/html"]));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("sortByPrecedence", () => {
|
|
134
|
+
it("should sort entries by precedence order", () => {
|
|
135
|
+
const entries: Array<[MimeType, string]> = [
|
|
136
|
+
["text/plain", "plain"],
|
|
137
|
+
["text/html", "html"],
|
|
138
|
+
["image/png", "png"],
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const result = sortByPrecedence(
|
|
142
|
+
entries,
|
|
143
|
+
precedenceMap(["text/html", "image/png", "text/plain"]),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(result.map(([m]) => m)).toEqual([
|
|
147
|
+
"text/html",
|
|
148
|
+
"image/png",
|
|
149
|
+
"text/plain",
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should place unknown mime types at the end", () => {
|
|
154
|
+
const entries: Array<[MimeType, string]> = [
|
|
155
|
+
["text/plain", "plain"],
|
|
156
|
+
["text/html", "html"],
|
|
157
|
+
["application/json", "json"],
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const result = sortByPrecedence(entries, precedenceMap(["text/html"]));
|
|
161
|
+
|
|
162
|
+
expect(result[0][0]).toBe("text/html");
|
|
163
|
+
expect(result.slice(1).map(([m]) => m)).toEqual([
|
|
164
|
+
"text/plain",
|
|
165
|
+
"application/json",
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should handle empty entries", () => {
|
|
170
|
+
const result = sortByPrecedence([], precedenceMap(["text/html"]));
|
|
171
|
+
|
|
172
|
+
expect(result).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should handle empty precedence", () => {
|
|
176
|
+
const entries: Array<[MimeType, string]> = [
|
|
177
|
+
["text/plain", "plain"],
|
|
178
|
+
["text/html", "html"],
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
const result = sortByPrecedence(entries, precedenceMap([]));
|
|
182
|
+
|
|
183
|
+
expect(result.map(([m]) => m)).toEqual(["text/plain", "text/html"]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("should not mutate original array", () => {
|
|
187
|
+
const entries: Array<[MimeType, string]> = [
|
|
188
|
+
["text/plain", "plain"],
|
|
189
|
+
["text/html", "html"],
|
|
190
|
+
];
|
|
191
|
+
const original = [...entries];
|
|
192
|
+
|
|
193
|
+
sortByPrecedence(entries, precedenceMap(["text/html", "text/plain"]));
|
|
194
|
+
|
|
195
|
+
expect(entries).toEqual(original);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("processMimeBundle", () => {
|
|
200
|
+
it("should filter and sort mime entries", () => {
|
|
201
|
+
const entries: Array<[MimeType, string]> = [
|
|
202
|
+
["text/plain", "plain"],
|
|
203
|
+
["text/html", "html"],
|
|
204
|
+
["image/png", "png"],
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
const config = createMimeConfig({
|
|
208
|
+
precedence: ["text/html", "text/plain"],
|
|
209
|
+
hidingRules: { "text/html": ["image/png"] },
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const result = processMimeBundle(entries, config);
|
|
213
|
+
|
|
214
|
+
expect(result.entries.map(([m]) => m)).toEqual([
|
|
215
|
+
"text/html",
|
|
216
|
+
"text/plain",
|
|
217
|
+
]);
|
|
218
|
+
expect(result.hidden).toEqual(["image/png"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("should handle empty entries", () => {
|
|
222
|
+
const result = processMimeBundle([]);
|
|
223
|
+
|
|
224
|
+
expect(result.entries).toEqual([]);
|
|
225
|
+
expect(result.hidden).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should use default config when not provided", () => {
|
|
229
|
+
const entries: Array<[MimeType, string]> = [
|
|
230
|
+
["text/html", "html"],
|
|
231
|
+
["image/png", "png"],
|
|
232
|
+
["text/markdown", "md"],
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const result = processMimeBundle(entries);
|
|
236
|
+
|
|
237
|
+
expect(result.entries.map(([m]) => m)).not.toContain("image/png");
|
|
238
|
+
expect(result.entries.map(([m]) => m)).toContain("text/markdown");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("should preserve data associated with mime types", () => {
|
|
242
|
+
const htmlData = { content: "<h1>Hello</h1>" };
|
|
243
|
+
const entries: Array<[MimeType, typeof htmlData]> = [
|
|
244
|
+
["text/html", htmlData],
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
const result = processMimeBundle(entries);
|
|
248
|
+
|
|
249
|
+
expect(result.entries[0][1]).toBe(htmlData);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("should sort by precedence after filtering", () => {
|
|
253
|
+
const entries: Array<[MimeType, string]> = [
|
|
254
|
+
["text/plain", "plain"],
|
|
255
|
+
["text/markdown", "md"],
|
|
256
|
+
["text/html", "html"],
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
const result = processMimeBundle(entries);
|
|
260
|
+
|
|
261
|
+
expect(result.entries[0][0]).toBe("text/html");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("getDefaultMimeConfig", () => {
|
|
266
|
+
const config = getDefaultMimeConfig();
|
|
267
|
+
|
|
268
|
+
it("should have text/html as highest precedence", () => {
|
|
269
|
+
expect(config.precedence.get("text/html")).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("should hide image types when html is present", () => {
|
|
273
|
+
const htmlHides = config.hidingRules.get("text/html");
|
|
274
|
+
|
|
275
|
+
expect(htmlHides).toBeDefined();
|
|
276
|
+
expect(htmlHides?.has("image/png")).toBe(true);
|
|
277
|
+
expect(htmlHides?.has("image/jpeg")).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("should NOT hide markdown when html is present", () => {
|
|
281
|
+
const htmlHides = config.hidingRules.get("text/html");
|
|
282
|
+
|
|
283
|
+
expect(htmlHides?.has("text/markdown")).toBeFalsy();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("should hide images when vega charts are present", () => {
|
|
287
|
+
const vegaHides = config.hidingRules.get(
|
|
288
|
+
"application/vnd.vegalite.v5+json",
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
expect(vegaHides).toBeDefined();
|
|
292
|
+
expect(vegaHides?.has("image/png")).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("should return the same instance on repeated calls", () => {
|
|
296
|
+
expect(getDefaultMimeConfig()).toBe(config);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("createMimeConfig", () => {
|
|
301
|
+
it("should compile precedence array into a Map", () => {
|
|
302
|
+
const config = createMimeConfig({
|
|
303
|
+
precedence: ["text/html", "image/png"],
|
|
304
|
+
hidingRules: {},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(config.precedence.get("text/html")).toBe(0);
|
|
308
|
+
expect(config.precedence.get("image/png")).toBe(1);
|
|
309
|
+
expect(config.precedence.has("text/plain")).toBe(false);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("should compile hiding rules into Map<MimeType, Set>", () => {
|
|
313
|
+
const config = createMimeConfig({
|
|
314
|
+
precedence: [],
|
|
315
|
+
hidingRules: {
|
|
316
|
+
"text/html": ["image/png", "image/jpeg"],
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const htmlHides = config.hidingRules.get("text/html");
|
|
321
|
+
expect(htmlHides).toBeInstanceOf(Set);
|
|
322
|
+
expect(htmlHides?.has("image/png")).toBe(true);
|
|
323
|
+
expect(htmlHides?.has("image/jpeg")).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { MimeType } from "@/components/editor/Output";
|
|
4
|
+
import { once } from "./once";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration for mime type precedence and filtering.
|
|
8
|
+
* Uses Map/Set for O(1) lookups at runtime.
|
|
9
|
+
*/
|
|
10
|
+
export interface MimeTypeConfig {
|
|
11
|
+
/**
|
|
12
|
+
* Pre-computed precedence map: mime type -> sort index.
|
|
13
|
+
* Lower index = higher priority. Types not in the map are placed at the end.
|
|
14
|
+
*/
|
|
15
|
+
precedence: ReadonlyMap<MimeType, number>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hiding rules: trigger mime type -> set of mime types to hide.
|
|
19
|
+
* When the key mime type is present, all mime types in the value set are hidden.
|
|
20
|
+
*/
|
|
21
|
+
hidingRules: ReadonlyMap<MimeType, ReadonlySet<MimeType>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Result of processing mime types through the filtering and sorting pipeline.
|
|
26
|
+
*/
|
|
27
|
+
export interface ProcessedMimeTypes<T> {
|
|
28
|
+
/** The filtered and sorted mime entries */
|
|
29
|
+
entries: Array<[MimeType, T]>;
|
|
30
|
+
/** Mime types that were hidden by rules */
|
|
31
|
+
hidden: MimeType[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a compiled MimeTypeConfig from readable arrays.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const config = createMimeConfig({
|
|
40
|
+
* precedence: ["text/html", "image/png", "text/plain"],
|
|
41
|
+
* hidingRules: {
|
|
42
|
+
* "text/html": ["image/png", "image/jpeg"],
|
|
43
|
+
* },
|
|
44
|
+
* });
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function createMimeConfig(input: {
|
|
48
|
+
precedence: MimeType[];
|
|
49
|
+
hidingRules: Record<string, MimeType[]>;
|
|
50
|
+
}): MimeTypeConfig {
|
|
51
|
+
const precedence = new Map<MimeType, number>();
|
|
52
|
+
for (let i = 0; i < input.precedence.length; i++) {
|
|
53
|
+
precedence.set(input.precedence[i], i);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hidingRules = new Map<MimeType, ReadonlySet<MimeType>>();
|
|
57
|
+
for (const [trigger, toHide] of Object.entries(input.hidingRules)) {
|
|
58
|
+
hidingRules.set(trigger as MimeType, new Set(toHide));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { precedence, hidingRules };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Default configuration for mime type handling.
|
|
66
|
+
* Lazily compiled on first access.
|
|
67
|
+
*
|
|
68
|
+
* Design rationale:
|
|
69
|
+
* - text/html typically contains rich rendered output and should take precedence
|
|
70
|
+
* - When text/html is present, image fallbacks (png, jpeg, etc.) are often redundant
|
|
71
|
+
* static renders and should be hidden to reduce UI clutter
|
|
72
|
+
* - text/markdown should NOT be hidden by text/html as they serve different purposes
|
|
73
|
+
* - Vega charts should remain visible as they provide interactivity
|
|
74
|
+
*/
|
|
75
|
+
export const getDefaultMimeConfig = once((): MimeTypeConfig => {
|
|
76
|
+
const IMAGE_FALLBACKS: MimeType[] = ["image/png", "image/jpeg", "image/gif"];
|
|
77
|
+
|
|
78
|
+
return createMimeConfig({
|
|
79
|
+
precedence: [
|
|
80
|
+
"text/html",
|
|
81
|
+
"application/vnd.vegalite.v6+json",
|
|
82
|
+
"application/vnd.vegalite.v5+json",
|
|
83
|
+
"application/vnd.vega.v6+json",
|
|
84
|
+
"application/vnd.vega.v5+json",
|
|
85
|
+
"image/svg+xml",
|
|
86
|
+
"image/png",
|
|
87
|
+
"image/jpeg",
|
|
88
|
+
"image/gif",
|
|
89
|
+
"text/markdown",
|
|
90
|
+
"text/latex",
|
|
91
|
+
"text/csv",
|
|
92
|
+
"application/json",
|
|
93
|
+
"text/plain",
|
|
94
|
+
"video/mp4",
|
|
95
|
+
"video/mpeg",
|
|
96
|
+
],
|
|
97
|
+
hidingRules: {
|
|
98
|
+
// When HTML is present, hide static image fallbacks
|
|
99
|
+
"text/html": [
|
|
100
|
+
...IMAGE_FALLBACKS,
|
|
101
|
+
"image/avif",
|
|
102
|
+
"image/bmp",
|
|
103
|
+
"image/tiff",
|
|
104
|
+
],
|
|
105
|
+
// When Vega charts are present, hide image fallbacks
|
|
106
|
+
"application/vnd.vegalite.v6+json": IMAGE_FALLBACKS,
|
|
107
|
+
"application/vnd.vegalite.v5+json": IMAGE_FALLBACKS,
|
|
108
|
+
"application/vnd.vega.v6+json": IMAGE_FALLBACKS,
|
|
109
|
+
"application/vnd.vega.v5+json": IMAGE_FALLBACKS,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Filters mime types based on hiding rules.
|
|
116
|
+
*/
|
|
117
|
+
export function applyHidingRules(
|
|
118
|
+
mimeTypes: ReadonlySet<MimeType>,
|
|
119
|
+
rules: ReadonlyMap<MimeType, ReadonlySet<MimeType>>,
|
|
120
|
+
): { visible: Set<MimeType>; hidden: Set<MimeType> } {
|
|
121
|
+
const hidden = new Set<MimeType>();
|
|
122
|
+
|
|
123
|
+
for (const mime of mimeTypes) {
|
|
124
|
+
const toHide = rules.get(mime);
|
|
125
|
+
if (toHide) {
|
|
126
|
+
for (const hideType of toHide) {
|
|
127
|
+
if (mimeTypes.has(hideType)) {
|
|
128
|
+
hidden.add(hideType);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const visible = new Set<MimeType>();
|
|
135
|
+
for (const mime of mimeTypes) {
|
|
136
|
+
if (!hidden.has(mime)) {
|
|
137
|
+
visible.add(mime);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { visible, hidden };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Sorts mime entries according to a precedence map.
|
|
146
|
+
* Mime types not in the map are placed at the end, preserving their original order.
|
|
147
|
+
*/
|
|
148
|
+
export function sortByPrecedence<T>(
|
|
149
|
+
entries: Array<[MimeType, T]>,
|
|
150
|
+
precedence: ReadonlyMap<MimeType, number>,
|
|
151
|
+
): Array<[MimeType, T]> {
|
|
152
|
+
const unknownPrecedence = precedence.size;
|
|
153
|
+
|
|
154
|
+
return [...entries].sort((a, b) => {
|
|
155
|
+
const indexA = precedence.get(a[0]) ?? unknownPrecedence;
|
|
156
|
+
const indexB = precedence.get(b[0]) ?? unknownPrecedence;
|
|
157
|
+
return indexA - indexB;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Main entry point: processes mime entries by applying hiding rules and sorting.
|
|
163
|
+
*/
|
|
164
|
+
export function processMimeBundle<T>(
|
|
165
|
+
entries: Array<[MimeType, T]>,
|
|
166
|
+
config: MimeTypeConfig = getDefaultMimeConfig(),
|
|
167
|
+
): ProcessedMimeTypes<T> {
|
|
168
|
+
if (entries.length === 0) {
|
|
169
|
+
return { entries: [], hidden: [] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const mimeTypes = new Set(entries.map(([mime]) => mime));
|
|
173
|
+
const { visible, hidden } = applyHidingRules(mimeTypes, config.hidingRules);
|
|
174
|
+
const filteredEntries = entries.filter(([mime]) => visible.has(mime));
|
|
175
|
+
const sortedEntries = sortByPrecedence(filteredEntries, config.precedence);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
entries: sortedEntries,
|
|
179
|
+
hidden: Array.from(hidden),
|
|
180
|
+
};
|
|
181
|
+
}
|