Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions marimo/_cli/export/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import asyncio
import os
import sys
from dataclasses import replace
from pathlib import Path
from typing import TYPE_CHECKING, Literal

Expand All @@ -22,14 +23,18 @@
)
from marimo._cli.sandbox import maybe_prompt_run_in_sandbox, run_in_sandbox
from marimo._cli.utils import prompt_to_overwrite
from marimo._convert.converters import MarimoConvert
from marimo._convert.markdown.flavor import (
markdown_output_filename,
normalize_markdown_flavor,
)
from marimo._dependencies.dependencies import DependencyManager
from marimo._dependencies.errors import ManyModulesNotFoundError
from marimo._pyodide.pyodide_constraints import PYODIDE_PYTHON_VERSION
from marimo._server.api.utils import parse_title
from marimo._server.export import (
ExportResult,
export_as_ipynb,
export_as_md,
export_as_script,
export_as_wasm,
notebook_uses_slides_layout,
Expand All @@ -48,6 +53,8 @@
if TYPE_CHECKING:
from collections.abc import Callable

from marimo._convert.markdown.flavor.base import MarkdownFlavorName

_watch_message = (
"Watch notebook for changes and regenerate the output on modification. "
"If watchdog is installed, it will be used to watch the file. "
Expand Down Expand Up @@ -169,6 +176,39 @@ async def start() -> None:
asyncio_run(start())


def _export_as_markdown(
path: MarimoPath,
*,
flavor: MarkdownFlavorName | None,
filename: str | None,
) -> ExportResult:
if path.is_python():
converter = MarimoConvert.from_py(path.read_text(encoding="utf-8"))
elif path.is_markdown():
converter = MarimoConvert.from_md(path.read_text(encoding="utf-8"))
else:
raise click.ClickException(
f"Unsupported file type: {path.path.suffix}"
)

ir = replace(converter.ir, filename=path.short_name)
source_filename = ir.filename or path.short_name
export_filename = filename or source_filename
markdown_flavor = normalize_markdown_flavor(
flavor, filename=export_filename
)
return ExportResult(
contents=MarimoConvert.from_ir(ir).to_markdown(
filename=source_filename,
flavor=markdown_flavor,
),
download_filename=markdown_output_filename(
export_filename, markdown_flavor
),
did_error=False,
)


@click.command(
cls=ColoredCommand,
help="""Run a notebook and export it as an HTML file.
Expand Down Expand Up @@ -353,7 +393,9 @@ def export_callback(file_path: MarimoPath) -> ExportResult:
default=None,
help=(
"Output file to save the markdown to. "
"If not provided, markdown will be printed to stdout."
"If --flavor is omitted, this file's extension selects the "
"markdown flavor. If not provided, markdown will be printed to "
"stdout; shell redirection is not inspected for flavor inference."
),
)
@click.option(
Expand All @@ -363,6 +405,12 @@ def export_callback(file_path: MarimoPath) -> ExportResult:
type=bool,
help=_sandbox_message,
)
@click.option(
Comment thread
peter-gy marked this conversation as resolved.
"--flavor",
type=click.Choice(["pymdown", "qmd", "mystmd"]),
default=None,
help="Markdown flavor to export.",
)
@click.option(
"-f",
"--force",
Expand All @@ -376,7 +424,12 @@ def export_callback(file_path: MarimoPath) -> ExportResult:
type=click.Path(exists=True, file_okay=True, dir_okay=False),
)
def md(
name: str, output: Path, watch: bool, sandbox: bool | None, force: bool
name: str,
output: Path,
watch: bool,
sandbox: bool | None,
flavor: MarkdownFlavorName | None,
force: bool,
) -> None:
"""
Export a marimo notebook as a code fenced markdown document.
Expand All @@ -385,8 +438,10 @@ def md(
run_in_sandbox(sys.argv[1:], name=name)
return

filename = str(output) if output is not None else None

def export_callback(file_path: MarimoPath) -> ExportResult:
return export_as_md(file_path)
return _export_as_markdown(file_path, flavor=flavor, filename=filename)

return watch_and_export(
MarimoPath(name), output, watch, export_callback, force
Expand Down
16 changes: 14 additions & 2 deletions marimo/_convert/converters.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

from typing import TYPE_CHECKING

from marimo._schemas.notebook import NotebookV1
from marimo._schemas.serialization import (
EMPTY_NOTEBOOK_SERIALIZATION,
NotebookSerialization,
)

if TYPE_CHECKING:
from marimo._convert.markdown.flavor.base import (
MarkdownFlavor,
MarkdownFlavorName,
)


class MarimoConverterIntermediate:
"""Intermediate representation that allows chaining conversions."""
Expand All @@ -20,11 +28,15 @@ def to_notebook_v1(self) -> NotebookV1:

return convert_from_ir_to_notebook_v1(self.ir)

def to_markdown(self, filename: str | None = None) -> str:
def to_markdown(
self,
filename: str | None = None,
flavor: MarkdownFlavor | MarkdownFlavorName | None = None,
) -> str:
"""Convert to markdown format."""
from marimo._convert.markdown import convert_from_ir_to_markdown

return convert_from_ir_to_markdown(self.ir, filename)
return convert_from_ir_to_markdown(self.ir, filename, flavor=flavor)

def to_py(self) -> str:
"""Convert to python format."""
Expand Down
131 changes: 131 additions & 0 deletions marimo/_convert/markdown/flavor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

import os
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING

from marimo._convert.markdown.flavor.base import (
MarkdownFlavor,
MarkdownFlavorName,
MarkdownImportDialect,
)
from marimo._convert.markdown.flavor.mystmd import (
MystmdMarkdownFlavor,
_MystmdMarkdownImportDialect,
)
from marimo._convert.markdown.flavor.pymdown import PymdownMarkdownFlavor
from marimo._convert.markdown.flavor.qmd import QmdMarkdownFlavor

if TYPE_CHECKING:
from collections.abc import Mapping

_PYMDOWN_MARKDOWN = PymdownMarkdownFlavor()
_QMD_MARKDOWN = QmdMarkdownFlavor()
_MYSTMD_MARKDOWN = MystmdMarkdownFlavor()
_MYSTMD_MARKDOWN_IMPORT = _MystmdMarkdownImportDialect()
_MARKDOWN_FLAVORS: Mapping[MarkdownFlavorName, MarkdownFlavor] = (
MappingProxyType(
{
_PYMDOWN_MARKDOWN.name: _PYMDOWN_MARKDOWN,
_QMD_MARKDOWN.name: _QMD_MARKDOWN,
_MYSTMD_MARKDOWN.name: _MYSTMD_MARKDOWN,
}
)
)
_MARKDOWN_IMPORT_DIALECTS: Mapping[
MarkdownFlavorName, MarkdownImportDialect
] = MappingProxyType({_MYSTMD_MARKDOWN_IMPORT.name: _MYSTMD_MARKDOWN_IMPORT})
# Filename inference handles target-specific markdown extensions.
_MARKDOWN_FLAVORS_BY_EXTENSION: Mapping[str, MarkdownFlavor] = (
MappingProxyType({".myst.md": _MYSTMD_MARKDOWN, ".qmd": _QMD_MARKDOWN})
)
# Download and auto-export filenames use the selected flavor's suffix.
_MARKDOWN_OUTPUT_EXTENSIONS: Mapping[MarkdownFlavorName, str] = (
MappingProxyType(
{
"pymdown": "md",
"qmd": "qmd",
"mystmd": "myst.md",
}
)
)
# Strip known markdown suffixes before applying an output suffix.
_MARKDOWN_FILENAME_SUFFIXES = (
".myst.md",
".markdown",
".qmd",
".md",
)


def default_markdown_flavor() -> MarkdownFlavor:
return _PYMDOWN_MARKDOWN


def markdown_flavor_from_filename(filename: str) -> MarkdownFlavor:
"""Infer the export flavor from a filename extension."""
suffixes = Path(filename).suffixes
for suffix in ("".join(suffixes[-2:]), suffixes[-1] if suffixes else ""):
if suffix in _MARKDOWN_FLAVORS_BY_EXTENSION:
return _MARKDOWN_FLAVORS_BY_EXTENSION[suffix]
return default_markdown_flavor()


def normalize_markdown_flavor(
flavor: MarkdownFlavor | MarkdownFlavorName | None,
*,
filename: str,
) -> MarkdownFlavor:
"""Resolve an optional flavor name or instance to a concrete flavor."""
if flavor is None:
return markdown_flavor_from_filename(filename)
if isinstance(flavor, MarkdownFlavor):
return flavor
try:
return _MARKDOWN_FLAVORS[flavor]
except KeyError as error:
raise ValueError(f"Unsupported markdown flavor: {flavor!r}") from error


def _markdown_import_dialects(
text: str, filepath: str | None
) -> tuple[MarkdownImportDialect, ...]:
return tuple(
dialect
for dialect in _MARKDOWN_IMPORT_DIALECTS.values()
if dialect.matches(text, filepath)
)


def _markdown_output_extension(
flavor: MarkdownFlavor | MarkdownFlavorName,
) -> str:
flavor_name = flavor.name if isinstance(flavor, MarkdownFlavor) else flavor
return _MARKDOWN_OUTPUT_EXTENSIONS[flavor_name]


def markdown_output_filename(
filename: str | None,
flavor: MarkdownFlavor | MarkdownFlavorName,
) -> str:
"""Return the filename for a rendered markdown artifact.

Known markdown suffixes are replaced with the selected flavor's suffix.
For example, exporting `notebook.myst.md` as pymdown returns `notebook.md`.
"""
extension = _markdown_output_extension(flavor)
basename = os.path.basename(filename or f"notebook.{extension}")
for suffix in _MARKDOWN_FILENAME_SUFFIXES:
if basename.endswith(suffix):
return f"{basename[: -len(suffix)]}.{extension}"
return f"{os.path.splitext(basename)[0]}.{extension}"


__all__ = [
"default_markdown_flavor",
"markdown_flavor_from_filename",
"markdown_output_filename",
"normalize_markdown_flavor",
]
Loading
Loading