@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/dist/main.js +2594 -2718
- package/dist/{reveal-component-agH2Be6_.js → reveal-component-CFuofbBD.js} +826 -560
- package/dist/{slide-CoAyRjHI.js → slide-form-DgMI37ES.js} +1489 -773
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/editor/output/JsonOutput.tsx +42 -12
- package/src/components/editor/output/__tests__/json-output.test.ts +38 -0
- package/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts +150 -0
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +298 -0
- package/src/components/editor/renderers/slides-layout/compute-slide-cells.ts +50 -0
- package/src/components/editor/renderers/slides-layout/plugin.tsx +54 -9
- package/src/components/editor/renderers/slides-layout/slides-layout.tsx +30 -12
- package/src/components/editor/renderers/slides-layout/types.ts +31 -3
- package/src/components/editor/renderers/types.ts +2 -0
- package/src/components/slides/__tests__/compose-slides.test.ts +433 -0
- package/src/components/slides/compose-slides.ts +337 -0
- package/src/components/slides/minimap.tsx +133 -12
- package/src/components/slides/reveal-component.tsx +337 -74
- package/src/components/slides/reveal-slides.css +33 -1
- package/src/components/slides/slide-form.tsx +347 -0
- package/src/components/ui/radio-group.tsx +5 -3
- package/src/core/cells/types.ts +2 -0
- package/src/core/layout/layout.ts +6 -2
package/package.json
CHANGED
|
@@ -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 =
|
|
393
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
418
|
-
|
|
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
|
+
}
|