@marimo-team/islands 0.23.3-dev44 → 0.23.3-dev48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.3-dev44",
3
+ "version": "0.23.3-dev48",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -18,6 +18,7 @@ import { memo, useState } from "react";
18
18
  import type { OutputMessage } from "@/core/kernel/messages";
19
19
  import { cn } from "@/utils/cn";
20
20
  import { copyToClipboard } from "@/utils/copy";
21
+ import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
21
22
  import { isUrl } from "@/utils/urls";
22
23
  import { useTheme } from "../../../theme/useTheme";
23
24
  import { logNever } from "../../../utils/assertNever";
@@ -386,14 +387,41 @@ function renderLeaf(leaf: string, render: LeafRenderer): React.ReactNode {
386
387
  // See `_key_formatter` in marimo/_output/formatters/structures.py.
387
388
  const KEY_ENCODED_PREFIX = "text/plain+";
388
389
 
390
+ // Format elements for a Python collection literal. Non-finite floats
391
+ // (NaN / Infinity / -Infinity) parse as JS `number` via
392
+ // `jsonParseWithSpecialChar`; `JSON.stringify` on those returns `null`,
393
+ // so render them as the same `float(...)` literals we use for scalar
394
+ // float keys (see `decodeKeyForCopy`).
395
+ function formatCollectionItems(items: unknown[]): string {
396
+ return items
397
+ .map((x) => {
398
+ if (typeof x === "number" && !Number.isFinite(x)) {
399
+ if (Number.isNaN(x)) {
400
+ return "float('nan')";
401
+ }
402
+ return x > 0 ? "float('inf')" : "-float('inf')";
403
+ }
404
+ return JSON.stringify(x);
405
+ })
406
+ .join(", ");
407
+ }
408
+
389
409
  // Format a JSON-list payload as a Python tuple literal. 1-element tuples
390
410
  // need a trailing comma — `(1)` is just `1` in Python, `(1,)` is the tuple.
411
+ // Uses `jsonParseWithSpecialChar` so bare `NaN`/`Infinity`/`-Infinity`
412
+ // emitted by Python's json.dumps round-trip cleanly.
391
413
  function formatTuplePayload(jsonList: string): string {
392
- const items = JSON.parse(jsonList) as unknown[];
393
- const inner = items.map((x) => JSON.stringify(x)).join(", ");
414
+ const items = jsonParseWithSpecialChar<unknown[]>(jsonList);
415
+ // `jsonParseWithSpecialChar` returns `{}` when both parse passes fail;
416
+ // fall back to the raw payload so a malformed wire form doesn't crash
417
+ // rendering/copy. Matches the defensive pattern in `formatSetPayload`.
418
+ if (!Array.isArray(items)) {
419
+ return jsonList;
420
+ }
394
421
  if (items.length === 0) {
395
422
  return "()";
396
423
  }
424
+ const inner = formatCollectionItems(items);
397
425
  if (items.length === 1) {
398
426
  return `(${inner},)`;
399
427
  }
@@ -403,29 +431,31 @@ function formatTuplePayload(jsonList: string): string {
403
431
  // Format a JSON-list payload as a Python frozenset literal. Empty → `frozenset()`
404
432
  // rather than `frozenset({})` (which reads like a dict).
405
433
  function formatFrozensetPayload(jsonList: string): string {
406
- const items = JSON.parse(jsonList) as unknown[];
434
+ const items = jsonParseWithSpecialChar<unknown[]>(jsonList);
435
+ if (!Array.isArray(items)) {
436
+ return jsonList;
437
+ }
407
438
  if (items.length === 0) {
408
439
  return "frozenset()";
409
440
  }
410
- const inner = items.map((x) => JSON.stringify(x)).join(", ");
441
+ const inner = formatCollectionItems(items);
411
442
  return `frozenset({${inner}})`;
412
443
  }
413
444
 
414
445
  // Format a JSON-list payload as a Python set literal. Empty → `set()`
415
446
  // (not `{}`, which is a dict literal in Python).
416
447
  function formatSetPayload(jsonList: string): string {
417
- try {
418
- const items = JSON.parse(jsonList) as unknown[];
419
- if (items.length === 0) {
420
- return "set()";
421
- }
422
- const inner = items.map((x) => JSON.stringify(x)).join(", ");
423
- return `{${inner}}`;
424
- } catch {
448
+ const items = jsonParseWithSpecialChar<unknown[]>(jsonList);
449
+ if (!Array.isArray(items)) {
425
450
  // Back-compat: older wire form was `text/plain+set:{1, 2, 3}` (Python
426
451
  // set-literal string, not JSON). Pass it through as-is rather than crash.
427
452
  return jsonList;
428
453
  }
454
+ if (items.length === 0) {
455
+ return "set()";
456
+ }
457
+ const inner = formatCollectionItems(items);
458
+ return `{${inner}}`;
429
459
  }
430
460
 
431
461
  // Renderers for decoded non-string keys. Visual affordances match Python:
@@ -417,6 +417,44 @@ describe("getCopyValue with encoded non-string keys", () => {
417
417
  `);
418
418
  });
419
419
 
420
+ it("parses tuple/frozenset payloads containing bare NaN/Infinity", () => {
421
+ // Python's json.dumps emits bare `NaN`/`Infinity` inside the embedded
422
+ // tuple/frozenset payload strings (JSON spec violation, but ECMA-262-
423
+ // friendly via the fallback in jsonParseWithSpecialChar). The outer
424
+ // JSON stays strict because those tokens live inside a JSON string
425
+ // key/value. Regression for tuple-key payloads that previously broke
426
+ // the frontend's `JSON.parse` and threw.
427
+ const value = {
428
+ "text/plain+tuple:[NaN]": "tn",
429
+ "text/plain+tuple:[Infinity, -Infinity]": "ti",
430
+ k: "text/plain+frozenset:[Infinity, 1]",
431
+ };
432
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
433
+ "{
434
+ (float('nan'),): "tn",
435
+ (float('inf'), -float('inf')): "ti",
436
+ "k": frozenset({float('inf'), 1})
437
+ }"
438
+ `);
439
+ });
440
+
441
+ it("falls back to the raw payload for malformed tuple/frozenset", () => {
442
+ // `jsonParseWithSpecialChar` returns `{}` on parse failure rather
443
+ // than throwing; without an `Array.isArray` guard, the formatters
444
+ // would crash on `.length`/`.map`. Pass the raw payload through so
445
+ // a malformed wire form doesn't break the whole render.
446
+ const value = {
447
+ "text/plain+tuple:not a json list": "t",
448
+ k: "text/plain+frozenset:also broken",
449
+ };
450
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
451
+ "{
452
+ not a json list: "t",
453
+ "k": also broken
454
+ }"
455
+ `);
456
+ });
457
+
420
458
  it("unescapes string keys that looked encoded", () => {
421
459
  const value = {
422
460
  "text/plain+str:text/plain+int:2": "hello",
@@ -0,0 +1,150 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import { computeSlideCellsInfo } from "../compute-slide-cells";
5
+ import type { SlideConfig, SlidesLayout } from "../types";
6
+ import type { CellId } from "@/core/cells/ids";
7
+
8
+ interface TestCell {
9
+ id: CellId;
10
+ output: { data: unknown } | null;
11
+ }
12
+
13
+ const DEFAULT_OUTPUT: TestCell["output"] = { data: "ok" };
14
+
15
+ const cell = (
16
+ id: string,
17
+ output: TestCell["output"] = DEFAULT_OUTPUT,
18
+ ): TestCell => ({ id: id as CellId, output });
19
+
20
+ const layoutOf = (entries: Array<[string, SlideConfig]>): SlidesLayout => ({
21
+ cells: new Map(entries.map(([id, cfg]) => [id as CellId, cfg])),
22
+ deck: {},
23
+ });
24
+
25
+ describe("computeSlideCellsInfo", () => {
26
+ it("returns empty results for empty input", () => {
27
+ const result = computeSlideCellsInfo([], layoutOf([]));
28
+ expect(result.cellsWithOutput).toEqual([]);
29
+ expect(result.skippedIds.size).toBe(0);
30
+ expect(result.slideTypes.size).toBe(0);
31
+ expect(result.startCellIndex).toBe(0);
32
+ });
33
+
34
+ it("computes firstNonSkippedIndex as the first non-skipped cell", () => {
35
+ const result = computeSlideCellsInfo(
36
+ [cell("a"), cell("b"), cell("c")],
37
+ layoutOf([
38
+ ["a", { type: "skip" }],
39
+ ["b", { type: "skip" }],
40
+ ["c", { type: "slide" }],
41
+ ]),
42
+ );
43
+ expect(result.startCellIndex).toBe(2);
44
+ });
45
+
46
+ it("falls back to 0 when every cell is skipped", () => {
47
+ // If the user marked everything as skip we still have to land somewhere;
48
+ // the renderer treats 0 as a safe default rather than rendering nothing.
49
+ const result = computeSlideCellsInfo(
50
+ [cell("a"), cell("b")],
51
+ layoutOf([
52
+ ["a", { type: "skip" }],
53
+ ["b", { type: "skip" }],
54
+ ]),
55
+ );
56
+ expect(result.startCellIndex).toBe(0);
57
+ });
58
+
59
+ it("uses index 0 when no cells are skipped", () => {
60
+ const result = computeSlideCellsInfo([cell("a"), cell("b")], layoutOf([]));
61
+ expect(result.startCellIndex).toBe(0);
62
+ });
63
+
64
+ it("filters out cells with no output", () => {
65
+ const result = computeSlideCellsInfo(
66
+ [cell("a"), cell("b", null), cell("c")],
67
+ layoutOf([]),
68
+ );
69
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "c"]);
70
+ });
71
+
72
+ it("filters out cells whose output data is empty string", () => {
73
+ // Mirrors the editor contract: an explicit empty-string payload means the
74
+ // cell rendered nothing, so it should not occupy a slide.
75
+ const result = computeSlideCellsInfo(
76
+ [cell("a"), cell("b", { data: "" }), cell("c")],
77
+ layoutOf([]),
78
+ );
79
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "c"]);
80
+ });
81
+
82
+ it("keeps cells whose output data is a non-empty value (including falsy ones)", () => {
83
+ // Only "" is treated as empty — 0 / false / null-shaped payloads still
84
+ // represent rendered output and should stay in the deck.
85
+ const result = computeSlideCellsInfo(
86
+ [
87
+ cell("a", { data: 0 }),
88
+ cell("b", { data: false }),
89
+ cell("c", { data: null }),
90
+ ],
91
+ layoutOf([]),
92
+ );
93
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "b", "c"]);
94
+ });
95
+
96
+ it("populates slideTypes only for cells with an explicit type", () => {
97
+ const result = computeSlideCellsInfo(
98
+ [cell("a"), cell("b"), cell("c")],
99
+ layoutOf([
100
+ ["a", { type: "slide" }],
101
+ ["b", {}],
102
+ ["c", { type: "fragment" }],
103
+ ]),
104
+ );
105
+ expect(Object.fromEntries(result.slideTypes)).toEqual({
106
+ a: "slide",
107
+ c: "fragment",
108
+ });
109
+ });
110
+
111
+ it("tracks skipped cells in skippedIds", () => {
112
+ const result = computeSlideCellsInfo(
113
+ [cell("a"), cell("b"), cell("c")],
114
+ layoutOf([
115
+ ["a", { type: "slide" }],
116
+ ["b", { type: "skip" }],
117
+ ["c", { type: "skip" }],
118
+ ]),
119
+ );
120
+ expect([...result.skippedIds]).toEqual(["b", "c"]);
121
+ // Skipped cells are still "visible" deck cells — they just aren't rendered
122
+ // in reveal. The minimap relies on the full list plus skippedIds.
123
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "b", "c"]);
124
+ expect(result.slideTypes.get("b" as CellId)).toBe("skip");
125
+ });
126
+
127
+ it("ignores layout entries for cells that have no output", () => {
128
+ // If a cell was skipped in the layout but no longer produces output (e.g.
129
+ // the user deleted its code), it should drop out of both maps — otherwise
130
+ // the skip set would reference ghosts.
131
+ const result = computeSlideCellsInfo(
132
+ [cell("a"), cell("b", null)],
133
+ layoutOf([
134
+ ["a", { type: "slide" }],
135
+ ["b", { type: "skip" }],
136
+ ]),
137
+ );
138
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a"]);
139
+ expect(result.skippedIds.size).toBe(0);
140
+ expect(result.slideTypes.has("b" as CellId)).toBe(false);
141
+ });
142
+
143
+ it("preserves the input order of cells in cellsWithOutput", () => {
144
+ const result = computeSlideCellsInfo(
145
+ [cell("c"), cell("a"), cell("b")],
146
+ layoutOf([]),
147
+ );
148
+ expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["c", "a", "b"]);
149
+ });
150
+ });
@@ -0,0 +1,298 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import { SlidesLayoutPlugin } from "../plugin";
5
+ import type { CellData } from "@/core/cells/types";
6
+ import type { CellId } from "@/core/cells/ids";
7
+
8
+ function makeCell(id: string, code = "print('hi')"): CellData {
9
+ return {
10
+ id: id as CellId,
11
+ name: id,
12
+ code,
13
+ edited: false,
14
+ lastCodeRun: null,
15
+ lastExecutionTime: null,
16
+ config: { hide_code: false, disabled: false, column: null },
17
+ serializedEditorState: null,
18
+ };
19
+ }
20
+
21
+ describe("SlidesLayoutPlugin validator", () => {
22
+ it("accepts the legacy empty-object shape written by older marimo versions", () => {
23
+ // Backwards compat: any slides file saved before we introduced `cells` /
24
+ // `deck` looks like `{}` on disk. It must still validate.
25
+ expect(SlidesLayoutPlugin.validator.safeParse({}).success).toBe(true);
26
+ });
27
+
28
+ it("accepts a fully populated layout", () => {
29
+ expect(
30
+ SlidesLayoutPlugin.validator.safeParse({
31
+ cells: [{ type: "slide" }, {}],
32
+ deck: { transition: "fade" },
33
+ }).success,
34
+ ).toBe(true);
35
+ });
36
+
37
+ it("rejects unknown slide types", () => {
38
+ expect(
39
+ SlidesLayoutPlugin.validator.safeParse({
40
+ cells: [{ type: "bogus" }],
41
+ }).success,
42
+ ).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe("SlidesLayoutPlugin deserializeLayout", () => {
47
+ it("returns an empty map when serialized.cells is missing", () => {
48
+ // Regression: previously accessed `serialized.cells.length` on undefined
49
+ // and threw, preventing the app from initializing.
50
+ const layout = SlidesLayoutPlugin.deserializeLayout({}, [makeCell("a")]);
51
+ expect(layout.cells.size).toBe(0);
52
+ expect(layout.deck).toEqual({});
53
+ });
54
+
55
+ it("tolerates missing deck", () => {
56
+ const layout = SlidesLayoutPlugin.deserializeLayout(
57
+ { cells: [{ type: "slide" }] },
58
+ [makeCell("a")],
59
+ );
60
+ expect(layout.deck).toEqual({});
61
+ expect(layout.cells.get("a" as CellId)).toEqual({ type: "slide" });
62
+ });
63
+
64
+ it("passes through every serialized entry by position (thin passthrough)", () => {
65
+ const layout = SlidesLayoutPlugin.deserializeLayout(
66
+ // Third entry carries an unknown key to prove the deserializer does not
67
+ // strip fields it does not recognize (forward-compat).
68
+ // oxlint-disable-next-line typescript/no-explicit-any
69
+ { cells: [{}, { type: "fragment" }, { notes: "x" } as any] },
70
+ [makeCell("a"), makeCell("b"), makeCell("c")],
71
+ );
72
+ expect([...layout.cells.keys()]).toEqual(["a", "b", "c"]);
73
+ expect(layout.cells.get("a" as CellId)).toEqual({});
74
+ expect(layout.cells.get("b" as CellId)).toEqual({ type: "fragment" });
75
+ expect(layout.cells.get("c" as CellId)).toEqual({ notes: "x" });
76
+ });
77
+ });
78
+
79
+ describe("SlidesLayoutPlugin serializeLayout", () => {
80
+ it("emits one entry per notebook cell (dense, positional)", () => {
81
+ // Even with no per-cell config, the output array must have one slot per
82
+ // notebook cell so positional alignment is preserved on reload.
83
+ const serialized = SlidesLayoutPlugin.serializeLayout(
84
+ { cells: new Map(), deck: {} },
85
+ [makeCell("a"), makeCell("b", "print(42)")],
86
+ );
87
+ expect(serialized.cells).toEqual([{}, {}]);
88
+ expect(serialized.deck).toEqual({});
89
+ });
90
+
91
+ it("passes user-set SlideConfig fields through unchanged", () => {
92
+ const cells = [makeCell("a"), makeCell("b"), makeCell("c")];
93
+ const serialized = SlidesLayoutPlugin.serializeLayout(
94
+ {
95
+ cells: new Map([["b" as CellId, { type: "fragment" }]]),
96
+ deck: {},
97
+ },
98
+ cells,
99
+ );
100
+ expect(serialized.cells).toHaveLength(3);
101
+ expect(serialized.cells?.[1]).toMatchObject({ type: "fragment" });
102
+ });
103
+
104
+ it("serializes deck config verbatim", () => {
105
+ const serialized = SlidesLayoutPlugin.serializeLayout(
106
+ { cells: new Map(), deck: { transition: "fade" } },
107
+ [makeCell("a")],
108
+ );
109
+ expect(serialized.deck).toEqual({ transition: "fade" });
110
+ });
111
+
112
+ it("passes through unknown SlideConfig fields (forward compat)", () => {
113
+ // When a future version adds a new SlideConfig field, serialize should
114
+ // not need updating. Exercise that by stuffing an arbitrary property
115
+ // into the in-memory config and checking it survives.
116
+ const cells = [makeCell("a")];
117
+ // oxlint-disable-next-line typescript/no-explicit-any
118
+ const futureConfig = { type: "slide", notes: "speaker notes" } as any;
119
+ const serialized = SlidesLayoutPlugin.serializeLayout(
120
+ { cells: new Map([["a" as CellId, futureConfig]]), deck: {} },
121
+ cells,
122
+ );
123
+ expect(serialized.cells?.[0]).toMatchObject({
124
+ type: "slide",
125
+ notes: "speaker notes",
126
+ });
127
+ });
128
+
129
+ it("round-trips user-set config through serialize + deserialize", () => {
130
+ const cells = [makeCell("a"), makeCell("b")];
131
+ const before = {
132
+ cells: new Map([["b" as CellId, { type: "fragment" as const }]]),
133
+ deck: { transition: "fade" as const },
134
+ };
135
+ const serialized = SlidesLayoutPlugin.serializeLayout(before, cells);
136
+ const after = SlidesLayoutPlugin.deserializeLayout(serialized, cells);
137
+ expect(after.deck).toEqual({ transition: "fade" });
138
+ expect(after.cells.get("b" as CellId)).toMatchObject({ type: "fragment" });
139
+ });
140
+ });
141
+
142
+ /**
143
+ * Frozen snapshots of every on-disk shape this plugin has ever had to parse.
144
+ *
145
+ * RULES FOR THIS BLOCK:
146
+ * - Never delete or modify an existing snapshot — users have `.slides.json`
147
+ * files in these exact shapes sitting on their disks.
148
+ * - When the on-disk shape evolves, add a new snapshot here (with the release
149
+ * / commit that introduced it) so we keep proving we can load the old ones.
150
+ * - If you need to change the serializer's output, add a snapshot of the new
151
+ * shape to `serializedSnapshot` so a diff will surface the change in review.
152
+ *
153
+ * Every snapshot is required to:
154
+ * 1. pass the zod validator (today it is defined but not applied on load;
155
+ * this guards against regressions once it is),
156
+ * 2. deserialize without throwing,
157
+ * 3. survive a deserialize → serialize → deserialize round trip without
158
+ * throwing or losing any user-set field carried in the expectations.
159
+ */
160
+ interface BackwardsCompatCase {
161
+ label: string;
162
+ input: unknown;
163
+ /**
164
+ * Expected state after deserializing `input` against `cells`. Only asserted
165
+ * properties need to be listed — extra properties are ignored.
166
+ */
167
+ expected: {
168
+ deck?: unknown;
169
+ cellIds: string[];
170
+ cellEntries?: Array<[string, unknown]>;
171
+ };
172
+ }
173
+
174
+ const BACKWARDS_COMPAT_SNAPSHOTS: BackwardsCompatCase[] = [
175
+ {
176
+ // Shape written by every marimo version before we added per-slide config.
177
+ label: "legacy bare {} (pre-slide-config)",
178
+ input: {},
179
+ expected: { deck: {}, cellIds: [] },
180
+ },
181
+ {
182
+ // Current serializer output as of this commit. If you change the
183
+ // serializer, add the new shape as an additional snapshot — don't edit
184
+ // this one.
185
+ label: "current: cells + deck",
186
+ input: {
187
+ cells: [{ type: "slide" }, {}, { type: "fragment" }],
188
+ deck: { transition: "slide" },
189
+ },
190
+ expected: {
191
+ deck: { transition: "slide" },
192
+ cellIds: ["a", "b", "c"],
193
+ cellEntries: [
194
+ ["a", { type: "slide" }],
195
+ ["c", { type: "fragment" }],
196
+ ],
197
+ },
198
+ },
199
+ {
200
+ // Defensive: if a future version adds a new SlideConfig field and a user
201
+ // downgrades, we must not crash on unknown keys.
202
+ label: "forward-compat: unknown SlideConfig fields present",
203
+ input: {
204
+ cells: [{ type: "slide", notes: "x", background: "#000" }],
205
+ },
206
+ expected: {
207
+ deck: {},
208
+ cellIds: ["a"],
209
+ cellEntries: [["a", { type: "slide" }]],
210
+ },
211
+ },
212
+ ];
213
+
214
+ describe("SlidesLayoutPlugin backwards compatibility", () => {
215
+ it.each(BACKWARDS_COMPAT_SNAPSHOTS)(
216
+ "loads snapshot: $label",
217
+ ({ input, expected }) => {
218
+ const cells = expected.cellIds.map((id) => makeCell(id));
219
+
220
+ // 1. Validator must accept the shape.
221
+ const parsed = SlidesLayoutPlugin.validator.safeParse(input);
222
+ expect(
223
+ parsed.success,
224
+ `validator rejected: ${JSON.stringify(input)}`,
225
+ ).toBe(true);
226
+
227
+ // 2. Deserialize must succeed and reflect the user-set fields.
228
+ const layout = SlidesLayoutPlugin.deserializeLayout(
229
+ // Use the raw input (not the validator output) because that is what
230
+ // `deserializeLayout` actually receives in production today.
231
+ // oxlint-disable-next-line typescript/no-explicit-any
232
+ input as any,
233
+ cells,
234
+ );
235
+ if (expected.deck !== undefined) {
236
+ expect(layout.deck).toEqual(expected.deck);
237
+ }
238
+ for (const [cellId, expectedConfig] of expected.cellEntries ?? []) {
239
+ expect(layout.cells.get(cellId as CellId)).toMatchObject(
240
+ expectedConfig as object,
241
+ );
242
+ }
243
+
244
+ // 3. Round trip must not throw and must preserve the same user fields.
245
+ const reserialized = SlidesLayoutPlugin.serializeLayout(layout, cells);
246
+ expect(
247
+ SlidesLayoutPlugin.validator.safeParse(reserialized).success,
248
+ `serializer produced a shape that no longer validates: ${JSON.stringify(reserialized)}`,
249
+ ).toBe(true);
250
+ const redeserialized = SlidesLayoutPlugin.deserializeLayout(
251
+ reserialized,
252
+ cells,
253
+ );
254
+ for (const [cellId, expectedConfig] of expected.cellEntries ?? []) {
255
+ expect(redeserialized.cells.get(cellId as CellId)).toMatchObject(
256
+ expectedConfig as object,
257
+ );
258
+ }
259
+ if (expected.deck !== undefined) {
260
+ expect(redeserialized.deck).toEqual(expected.deck);
261
+ }
262
+ },
263
+ );
264
+
265
+ it("current serializer output is captured by an inline snapshot", () => {
266
+ // If this snapshot changes you are changing the on-disk shape. That is
267
+ // allowed, but:
268
+ // 1. Add the previous shape to BACKWARDS_COMPAT_SNAPSHOTS above so we
269
+ // keep proving we can still load it.
270
+ // 2. Update this inline snapshot.
271
+ const cells = [makeCell("a", "print('hello')"), makeCell("b", "x = 1")];
272
+ const serialized = SlidesLayoutPlugin.serializeLayout(
273
+ {
274
+ cells: new Map([
275
+ ["a" as CellId, { type: "slide" }],
276
+ ["b" as CellId, { type: "fragment" }],
277
+ ]),
278
+ deck: { transition: "fade" },
279
+ },
280
+ cells,
281
+ );
282
+ expect(serialized).toMatchInlineSnapshot(`
283
+ {
284
+ "cells": [
285
+ {
286
+ "type": "slide",
287
+ },
288
+ {
289
+ "type": "fragment",
290
+ },
291
+ ],
292
+ "deck": {
293
+ "transition": "fade",
294
+ },
295
+ }
296
+ `);
297
+ });
298
+ });
@@ -0,0 +1,50 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { CellId } from "@/core/cells/ids";
4
+ import type { SlideType, SlidesLayout } from "./types";
5
+
6
+ export interface SlideCellLike {
7
+ id: CellId;
8
+ output: { data: unknown } | null;
9
+ }
10
+
11
+ export interface SlideCellsInfo<T extends SlideCellLike> {
12
+ cellsWithOutput: T[];
13
+ skippedIds: Set<CellId>;
14
+ slideTypes: Map<CellId, SlideType>;
15
+ // Index of the first cell in `cellsWithOutput` that is not skipped
16
+ startCellIndex: number;
17
+ }
18
+
19
+ export function computeSlideCellsInfo<T extends SlideCellLike>(
20
+ cells: readonly T[],
21
+ layout: Pick<SlidesLayout, "cells">,
22
+ ): SlideCellsInfo<T> {
23
+ const cellsWithOutput = cells.filter(
24
+ (cell) => cell.output != null && cell.output.data !== "",
25
+ );
26
+ const skippedIds = new Set<CellId>();
27
+ const slideTypes = new Map<CellId, SlideType>();
28
+
29
+ let startCell: T | null = null;
30
+ let startCellIndex = 0;
31
+
32
+ for (const [index, cell] of cellsWithOutput.entries()) {
33
+ const type = layout.cells.get(cell.id)?.type;
34
+ if (type) {
35
+ slideTypes.set(cell.id, type);
36
+ }
37
+ if (type === "skip") {
38
+ skippedIds.add(cell.id);
39
+ } else if (startCell === null) {
40
+ startCell = cell;
41
+ startCellIndex = index;
42
+ }
43
+ }
44
+ return {
45
+ cellsWithOutput,
46
+ skippedIds,
47
+ slideTypes,
48
+ startCellIndex,
49
+ };
50
+ }