/* Copyright 2026 Marimo. All rights reserved. */
import {
ISLAND_DATA_ATTRIBUTES,
ISLAND_SOURCE_CHANGED_EVENT,
ISLAND_TAG_NAMES,
ISLANDS_JSON_SCRIPT_TYPE,
} from "@/core/islands/constants";
import { Logger } from "@/utils/Logger";
import { isRecord } from "@/utils/records";
/**
* DOM elements look like this:
*
*
* Hello, world!
*
*
* encoded(print("Hello, world!"))
*
*
*/
export interface MarimoIslandApp {
/**
* ID since we allow multiple apps on the same page.
*/
id: string;
/**
* Whether cells came from a supported JSON payload instead of DOM parsing.
*/
payloadBacked?: boolean;
/** Explicit notebook dependencies to install before starting the app. */
dependencies?: string[];
/**
* Cells in the app.
*/
cells: MarimoIslandCell[];
}
interface MarimoIslandCell {
/**
* Output of the cell.
*/
output: string;
/**
* Code of the cell.
*/
code: string;
/**
* Index of the cell.
*/
idx: number;
/**
* Stable cell identifier, when provided by the island payload.
*/
cellId?: string;
/**
* Whether the generated marimo cell should be present but not executed.
*/
disabled?: boolean;
}
interface MarimoIslandPayload {
schemaVersion: 1;
appId: string;
dependencies?: string[];
cells: MarimoIslandPayloadCell[];
}
interface MarimoIslandPayloadCell {
cellId: string;
code: string;
outputHtml: string;
outputMimetype: string;
reactive: boolean;
displayCode: boolean;
displayOutput: boolean;
}
const retainedPayloadCellIds = new WeakMap();
/**
* Parses marimo island apps from the DOM
* @param root - Root element to search within (defaults to document)
*/
export function parseMarimoIslandApps(
root: Document | Element = document,
options: { materialize?: boolean } = {},
): MarimoIslandApp[] {
const embeds = [
...root.querySelectorAll(ISLAND_TAG_NAMES.ISLAND),
];
const payloads = parseMarimoIslandPayloads(root);
if (embeds.length === 0) {
Logger.warn("No embedded marimo apps found.");
return [];
}
const materialize = options.materialize ?? true;
if (payloads.length > 0) {
return parsePayloadBackedApps({ embeds, materialize, payloads });
}
return parseIslandElementsIntoApps(embeds, materialize);
}
/**
* Pure function to parse island elements into app structures
* @param embeds - Array of island HTML elements
*/
export function parseIslandElementsIntoApps(
embeds: HTMLElement[],
materialize = true,
): MarimoIslandApp[] {
const apps = new Map();
for (const embed of embeds) {
const appId = embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID);
if (!appId) {
Logger.warn("Embedded marimo cell missing data-app-id attribute.");
continue;
}
// Non-reactive islands are static — they don't participate in the kernel
const reactive =
embed.getAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE) === "true";
if (!reactive) {
continue;
}
const cellData = parseIslandElement(embed);
if (!cellData) {
Logger.warn(`Embedded marimo app ${appId} missing cell output or code.`);
continue;
}
let app = apps.get(appId);
if (!app) {
app = { id: appId, cells: [] };
apps.set(appId, app);
}
const idx = app.cells.length;
app.cells.push({
output: cellData.output,
code: cellData.code,
idx: idx,
});
// Add data-cell-idx attribute to the island element
if (materialize) {
embed.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX, idx.toString());
embed.dispatchEvent(new Event(ISLAND_SOURCE_CHANGED_EVENT));
}
}
return [...apps.values()];
}
function parsePayloadBackedApps({
embeds,
materialize,
payloads,
}: {
embeds: HTMLElement[];
materialize: boolean;
payloads: MarimoIslandPayload[];
}): MarimoIslandApp[] {
const apps = new Map();
const matchedPayloadCells = new Map();
const consumedEmbeds = new Set();
const acceptedPayloads: MarimoIslandPayload[] = [];
for (const payload of payloads) {
let hasMatchedIsland = false;
for (const cell of payload.cells) {
const embed = findMatchingIsland({
embeds,
appId: payload.appId,
cell,
consumedEmbeds,
});
if (!embed) {
continue;
}
consumedEmbeds.add(embed);
matchedPayloadCells.set(cell, embed);
if (materialize) {
materializeIslandPayload(embed, cell);
}
hasMatchedIsland = true;
}
// Only payloads matched to island anchors can start runtime apps.
if (hasMatchedIsland) {
acceptedPayloads.push(payload);
}
}
const payloadAppIds = new Set(
acceptedPayloads.map((payload) => payload.appId),
);
const reactivePayloadAppIds = new Set(
acceptedPayloads
.filter((payload) => payload.cells.some((cell) => cell.reactive))
.map((payload) => payload.appId),
);
for (const payload of acceptedPayloads) {
for (const cell of payload.cells) {
const embed = matchedPayloadCells.get(cell);
// Static-only payload apps render from HTML and do not need a Pyodide
// session.
if (!reactivePayloadAppIds.has(payload.appId)) {
continue;
}
let app = apps.get(payload.appId);
if (!app) {
app = { id: payload.appId, payloadBacked: true, cells: [] };
apps.set(payload.appId, app);
}
const idx = app.cells.length;
const appCell: MarimoIslandCell = {
cellId: cell.cellId,
output: cell.outputHtml,
code: cell.reactive ? cell.code : "",
idx: idx,
};
// Keep static cells in the generated file so later reactive cells keep
// stable runtime indices without executing static code.
if (!cell.reactive) {
appCell.disabled = true;
}
app.cells.push(appCell);
if (materialize && cell.reactive) {
embed?.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX, idx.toString());
}
}
const app = apps.get(payload.appId);
if (app && payload.dependencies?.length) {
app.dependencies = [
...new Set([...(app.dependencies ?? []), ...payload.dependencies]),
];
}
}
// A supported payload is the runtime source for its app. Extra same-app DOM
// islands are disconnected from runtime binding.
if (materialize) {
for (const embed of embeds) {
const appId = embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID);
if (appId && payloadAppIds.has(appId) && !consumedEmbeds.has(embed)) {
embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID);
embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX);
embed.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false");
consumedEmbeds.add(embed);
}
}
}
const domOnlyEmbeds = embeds.filter((embed) => {
const appId = embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID);
return !appId || !payloadAppIds.has(appId);
});
if (materialize) {
for (const embed of consumedEmbeds) {
embed.dispatchEvent(new Event(ISLAND_SOURCE_CHANGED_EVENT));
}
}
return [
...apps.values(),
...parseIslandElementsIntoApps(domOnlyEmbeds, materialize),
];
}
function findMatchingIsland({
embeds,
appId,
cell,
consumedEmbeds,
}: {
embeds: HTMLElement[];
appId: string;
cell: MarimoIslandPayloadCell;
consumedEmbeds: Set;
}): HTMLElement | undefined {
return embeds.find((embed) => {
if (consumedEmbeds.has(embed)) {
return false;
}
return (
embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID) === appId &&
(embed.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID) ??
retainedPayloadCellIds.get(embed)) === cell.cellId
);
});
}
function materializeIslandPayload(
embed: HTMLElement,
cell: MarimoIslandPayloadCell,
): void {
retainedPayloadCellIds.set(embed, cell.cellId);
embed.setAttribute(
ISLAND_DATA_ATTRIBUTES.REACTIVE,
JSON.stringify(cell.reactive),
);
// The runtime file is synthesized from payload order, so DOM anchors bind
// by index.
embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID);
if (!cell.reactive) {
embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX);
}
const output = ensureIslandChild(embed, ISLAND_TAG_NAMES.CELL_OUTPUT);
output.innerHTML = cell.displayOutput ? cell.outputHtml : "";
const code = ensureIslandChild(embed, ISLAND_TAG_NAMES.CELL_CODE);
code.hidden = true;
code.textContent = encodeURIComponent(cell.code);
const editor = embed.querySelector(ISLAND_TAG_NAMES.CODE_EDITOR);
if (editor) {
editor.setAttribute("data-initial-value", JSON.stringify(cell.code));
}
}
function ensureIslandChild(embed: HTMLElement, tagName: string): HTMLElement {
let child = embed.querySelector(tagName);
if (!child) {
child = embed.ownerDocument.createElement(tagName);
embed.appendChild(child);
}
return child;
}
/**
* Parses a single island element into cell data
* @param embed - The island HTML element
* @returns Cell data or null if invalid
*/
const retainedIslandSources = new WeakMap<
HTMLElement,
{ output: string; code: string }
>();
export function retainIslandSource(
embed: HTMLElement,
source: { output: string; code: string },
): void {
if (source.code) {
retainedIslandSources.set(embed, source);
} else {
retainedIslandSources.delete(embed);
}
}
export function parseIslandElement(
embed: HTMLElement,
): { output: string; code: string } | null {
const cellOutput = embed.querySelector(
ISLAND_TAG_NAMES.CELL_OUTPUT,
);
const code = extractIslandCodeFromEmbed(embed);
if (cellOutput && code) {
return {
output: cellOutput.innerHTML,
code: code,
};
}
const retained = retainedIslandSources.get(embed);
return retained?.code ? retained : null;
}
export function createMarimoFile(app: {
cells: { code: string; disabled?: boolean }[];
dependencies?: string[];
}): string {
const lines = [
"import marimo",
"app = marimo.App()",
app.cells
.map((cell) => {
// Disabled payload cells are placeholders. Emit pass so static code
// does not define names in the runtime graph.
const sourceCode = cell.disabled ? "" : cell.code;
const code = sourceCode
? sourceCode
.split("\n")
.map((line) => ` ${line}`)
.join("\n")
: " pass";
// TODO: Handle async cells better
// This is probably not the best way to check if the code is async
// Ideally this is pushed into the Python code
const isAsync = code.includes("await ");
const prefix = isAsync ? "async def" : "def";
const decorator = cell.disabled
? "@app.cell(disabled=True)"
: "@app.cell";
// Wrap in a function
return `${decorator}\n${prefix} __():\n${code}\n return`;
})
.join("\n"),
];
if (app.dependencies?.length) {
lines.unshift(
"# /// script",
`# dependencies = ${JSON.stringify(app.dependencies)}`,
"# ///",
);
}
return lines.join("\n");
}
export function parseIslandEditor(code: string | undefined | null): string {
if (!code) {
return "";
}
try {
return `${JSON.parse(code)}`;
} catch {
return code;
}
}
export function parseIslandCode(code: string | undefined | null): string {
if (!code) {
return "";
}
return decodeURIComponent(code).trim();
}
export function extractIslandCodeFromEmbed(embed: HTMLElement): string {
const reactive =
embed.getAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE) === "true";
// Non-reactive cells are not guaranteed to have code, and should be treated as
// such.
if (!reactive) {
return "";
}
const cellCodeElement = embed.querySelector(
ISLAND_TAG_NAMES.CELL_CODE,
);
if (cellCodeElement) {
return parseIslandCode(cellCodeElement.textContent);
}
const editorCodeElement = embed.querySelector(
ISLAND_TAG_NAMES.CODE_EDITOR,
);
if (editorCodeElement) {
return parseIslandEditor(
editorCodeElement.getAttribute("data-initial-value"),
);
}
return "";
}
function parseMarimoIslandPayloads(
root: Document | Element,
): MarimoIslandPayload[] {
const scripts = root.querySelectorAll(
`script[type="${ISLANDS_JSON_SCRIPT_TYPE}"]`,
);
const payloads: MarimoIslandPayload[] = [];
for (const script of scripts) {
if (isNestedIslandPayloadScript(script)) {
continue;
}
const payload = parseMarimoIslandPayload(script.textContent);
if (payload) {
payloads.push(payload);
}
}
return payloads;
}
function isNestedIslandPayloadScript(script: HTMLScriptElement): boolean {
return Boolean(
script.closest(ISLAND_TAG_NAMES.ISLAND) ||
script.closest(ISLAND_TAG_NAMES.CELL_OUTPUT),
);
}
function parseMarimoIslandPayload(
text: string | undefined | null,
): MarimoIslandPayload | null {
if (!text) {
return null;
}
try {
const payload = JSON.parse(text);
if (isMarimoIslandPayload(payload)) {
return payload;
}
} catch {
return null;
}
return null;
}
function isMarimoIslandPayload(
payload: unknown,
): payload is MarimoIslandPayload {
if (!isRecord(payload)) {
return false;
}
return (
payload.schemaVersion === 1 &&
typeof payload.appId === "string" &&
(payload.dependencies === undefined ||
(Array.isArray(payload.dependencies) &&
payload.dependencies.every((dep) => typeof dep === "string"))) &&
Array.isArray(payload.cells) &&
payload.cells.every(isMarimoIslandPayloadCell)
);
}
function isMarimoIslandPayloadCell(
cell: unknown,
): cell is MarimoIslandPayloadCell {
if (!isRecord(cell)) {
return false;
}
return (
typeof cell.cellId === "string" &&
typeof cell.code === "string" &&
typeof cell.outputHtml === "string" &&
typeof cell.outputMimetype === "string" &&
typeof cell.reactive === "boolean" &&
typeof cell.displayCode === "boolean" &&
typeof cell.displayOutput === "boolean"
);
}