@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 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$12, v = _temp5$8, y;
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$12(e) {
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$11)) v = "Ancestor stopped", y = "default", S = "text-secondary-foreground";
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$11(e) {
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: _[c2],
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
- e2.sort(_temp3$17), w = Tabs, z = c2, G = "vertical", M = "flex";
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(_temp4$10)
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, r) {
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-dev33"), showCodeInRunModeAtom = atom(true);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.19.7-dev33",
3
+ "version": "0.19.7-dev34",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -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 mimeEntries = Objects.entries(mimebundle as Record<string, unknown>)
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: mimebundle[first],
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
+ }