/* Copyright 2026 Marimo. All rights reserved. */ import { CodeIcon, FileIcon, FileTextIcon, GlobeIcon, ImageIcon, type LucideIcon, NotebookIcon, } from "lucide-react"; import { type ComponentType, type PropsWithChildren, useId } from "react"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { IPYNB_SORT_MODES, MARKDOWN_FLAVORS, PDF_PRESETS, SCRIPT_TYPES, type ExportFormat, type ExportOptions, type MarkdownFlavor, } from "./state"; export interface FormatOptionsProps { options: ExportOptions; updateOptions: UpdateExportOptions; disabled: boolean; } export type UpdateExportOptions = ( format: Format, options: Partial, ) => void; interface Choice { value: Value; label: string; description: string; } type ChoiceDetails = Omit, "value">; function defineChoices( values: readonly Value[], details: Record, ): readonly Choice[] { return values.map((value) => ({ value, ...details[value] })); } type MarkdownFormat = MarkdownFlavor | "automatic"; const MARKDOWN_FORMATS: readonly MarkdownFormat[] = [ "automatic", ...MARKDOWN_FLAVORS, ]; const MARKDOWN_FLAVOR_OPTIONS = defineChoices(MARKDOWN_FORMATS, { automatic: { label: "Automatic", description: "Chooses a format from the notebook filename. Defaults to Markdown (.md).", }, pymdown: { label: "Markdown (.md)", description: "Creates a .md file with Python code blocks.", }, qmd: { label: "Quarto (.qmd)", description: "Creates a .qmd file for Quarto.", }, mystmd: { label: "MyST (.myst.md)", description: "Creates a .myst.md file for MyST.", }, mdx: { label: "MDX (.mdx)", description: "Creates a .mdx file for MDX.", }, }); const IPYNB_SORT_OPTIONS = defineChoices(IPYNB_SORT_MODES, { "top-down": { label: "Top to bottom", description: "Keeps cells in the same order as this notebook.", }, topological: { label: "Dependency order", description: "Reorders cells so each cell appears after the cells it depends on.", }, }); const PDF_PRESET_OPTIONS = defineChoices(PDF_PRESETS, { document: { label: "Document", description: "Creates pages for reading or printing.", }, slides: { label: "Slides", description: "Creates presentation slides from the notebook.", }, }); const PDF_RENDERERS = ["browser", "latex"] as const; const PDF_RENDERER_OPTIONS = defineChoices(PDF_RENDERERS, { browser: { label: "Browser", description: "Uses browser rendering to preserve the notebook's visual output.", }, latex: { label: "LaTeX first", description: "Uses Pandoc and TeX when available, then falls back to browser rendering.", }, }); const SCRIPT_TYPE_OPTIONS = defineChoices(SCRIPT_TYPES, { source: { label: "Notebook source", description: "Downloads the saved notebook source for continued editing in marimo.", }, flat: { label: "Flat script", description: "Reorders cells so the Python script runs from top to bottom.", }, }); interface FormatDefinition { label: string; actionLabel: string; description: string; Icon: LucideIcon; Options: ComponentType; } export const FORMAT_DEFINITIONS: Record = { html: { label: "HTML", actionLabel: "Export HTML", description: "Save the current notebook as an HTML page.", Icon: GlobeIcon, Options: HTMLFormatOptions, }, markdown: { label: "Markdown", actionLabel: "Export Markdown", description: "Save code and text in a Markdown-based file. Cell outputs are not included.", Icon: FileTextIcon, Options: MarkdownFormatOptions, }, ipynb: { label: "Jupyter", actionLabel: "Export Jupyter notebook", description: "Open the notebook in Jupyter and other compatible tools.", Icon: NotebookIcon, Options: IPYNBFormatOptions, }, pdf: { label: "PDF", actionLabel: "Export PDF", description: "Save the notebook as a PDF for printing or sharing.", Icon: FileIcon, Options: PDFFormatOptions, }, script: { label: "Python", actionLabel: "Export flat script", description: "Save the notebook as a Python file.", Icon: CodeIcon, Options: ScriptFormatOptions, }, png: { label: "PNG", actionLabel: "Export PNG", description: "Capture the current app view as an image.", Icon: ImageIcon, Options: NoFormatOptions, }, }; function HTMLFormatOptions({ options, updateOptions, disabled, }: FormatOptionsProps) { return ( updateOptions("html", { includeCode }) } /> ); } function MarkdownFormatOptions({ options, updateOptions, disabled, }: FormatOptionsProps) { return ( updateOptions("markdown", { flavor: value === "automatic" ? null : value, }) } options={MARKDOWN_FLAVOR_OPTIONS} /> ); } function IPYNBFormatOptions({ options, updateOptions, disabled, }: FormatOptionsProps) { return ( updateOptions("ipynb", { sortMode })} options={IPYNB_SORT_OPTIONS} /> updateOptions("ipynb", { includeOutputs }) } /> ); } function PDFFormatOptions({ options, updateOptions, disabled, }: FormatOptionsProps) { return ( updateOptions("pdf", { preset })} options={PDF_PRESET_OPTIONS} /> updateOptions("pdf", { includeInputs }) } /> updateOptions("pdf", { includeOutputs }) } /> {options.pdf.preset === "document" ? ( updateOptions("pdf", { webpdf: renderer === "browser" }) } options={PDF_RENDERER_OPTIONS} /> ) : null} ); } function ScriptFormatOptions({ options, updateOptions, disabled, }: FormatOptionsProps) { return ( updateOptions("script", { type })} options={SCRIPT_TYPE_OPTIONS} /> ); } function NoFormatOptions() { return null; } function OptionsGroup({ children }: PropsWithChildren) { return
{children}
; } function SwitchOption({ label, descriptions, descriptionOverride, checked, disabled, onCheckedChange, }: { label: string; descriptions: { checked: string; unchecked: string; }; descriptionOverride?: string; checked: boolean; disabled?: boolean; onCheckedChange: (checked: boolean) => void; }) { const id = useId(); const descriptionId = `${id}-description`; const description = descriptionOverride ?? (checked ? descriptions.checked : descriptions.unchecked); const possibleDescriptions = [ descriptions.checked, descriptions.unchecked, ...(descriptionOverride ? [descriptionOverride] : []), ]; return (
); } function RadioOption({ label, value, disabled, onValueChange, options, }: { label: string; value: Value; disabled?: boolean; onValueChange: (value: Value) => void; options: readonly Choice[]; }) { const id = useId(); const descriptionId = `${id}-description`; const selectedDescription = options.find( (option) => option.value === value, )?.description; return (
{label}
option.description)} />
onValueChange(nextValue as Value)} className="flex shrink-0 flex-wrap gap-x-5 gap-y-2 sm:justify-end" aria-label={label} aria-describedby={descriptionId} disabled={disabled} > {options.map((option, index) => { const optionId = `${id}-${index}`; return (
); })}
); } function SelectOption({ label, value, disabled, onValueChange, options, }: { label: string; value: Value; disabled?: boolean; onValueChange: (value: Value) => void; options: readonly Choice[]; }) { const id = useId(); const descriptionId = `${id}-description`; const selectedDescription = options.find( (option) => option.value === value, )?.description; return (
option.description)} />
); } function OptionDescription({ id, description, possibleDescriptions, }: { id: string; description: string; possibleDescriptions: readonly string[]; }) { const sizingDescriptions = [...new Set(possibleDescriptions)].filter( (candidate) => candidate !== description, ); return (

{description}

{sizingDescriptions.map((candidate) => (

{candidate}

))}
); }