Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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
19 changes: 17 additions & 2 deletions marimo/_cli/export/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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 @@ -363,6 +365,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 +384,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 +398,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_md(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
112 changes: 112 additions & 0 deletions marimo/_convert/markdown/flavor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# 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,
)
from marimo._convert.markdown.flavor.mystmd import MystmdMarkdownFlavor
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()
_MARKDOWN_FLAVORS: Mapping[MarkdownFlavorName, MarkdownFlavor] = (
MappingProxyType(
{
_PYMDOWN_MARKDOWN.name: _PYMDOWN_MARKDOWN,
_QMD_MARKDOWN.name: _QMD_MARKDOWN,
_MYSTMD_MARKDOWN.name: _MYSTMD_MARKDOWN,
}
)
)
_MARKDOWN_FLAVORS_BY_EXTENSION: Mapping[str, MarkdownFlavor] = (
MappingProxyType({".myst.md": _MYSTMD_MARKDOWN, ".qmd": _QMD_MARKDOWN})
)
_MARKDOWN_OUTPUT_EXTENSIONS: Mapping[MarkdownFlavorName, str] = (
MappingProxyType(
{
"pymdown": "md",
"qmd": "qmd",
"mystmd": "myst.md",
}
)
)
_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_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 output filename for a rendered markdown flavor.

Output naming is registry policy, not part of the rendering protocol.
Known markdown suffixes are stripped longest-first before appending the
selected flavor's suffix, so `notebook.myst.md` exported as pymdown becomes
`notebook.md` instead of reusing the MyST-specific filename.
"""
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",
]
109 changes: 109 additions & 0 deletions marimo/_convert/markdown/flavor/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar, Literal

if TYPE_CHECKING:
from collections.abc import Iterator

MarkdownFlavorName = Literal["pymdown", "qmd", "mystmd"]


@dataclass(frozen=True)
class MarkdownCellBlock:
text: str


@dataclass(frozen=True)
class CodeCellBlock:
source: str
language: str
options: dict[str, str]


MarkdownExportBlock = MarkdownCellBlock | CodeCellBlock


@dataclass(frozen=True)
class MarkdownExportDocument:
metadata: dict[str, str | list[str]]
header: str | None
blocks: list[MarkdownExportBlock]


class MarkdownFlavor(ABC):
"""Markdown-family output flavor.

This class defines document assembly while keeping target-specific
metadata, markdown text, and code cell syntax delegated to concrete
flavors.
"""

name: ClassVar[MarkdownFlavorName]

@abstractmethod
def prepare_metadata(
self, metadata: dict[str, str | list[str]]
) -> dict[str, str | list[str]]:
"""Return metadata after flavor-specific normalization.

Flavors use this hook for metadata additions or removals before their
preamble renderer serializes the document.
"""

def render_document(self, document: MarkdownExportDocument) -> str:
"""Render a document by applying flavor-specific block renderers.

Consecutive markdown cells are separated by an HTML comment, while
transitions from markdown to executable code blocks get a blank
line. Flavors should override smaller render hooks before replacing
this whole assembly step.
"""

def render_blocks() -> Iterator[str]:
previous_was_markdown = False

for block in document.blocks:
if isinstance(block, MarkdownCellBlock):
if previous_was_markdown:
yield "<!---->"
previous_was_markdown = True
yield self.render_markdown(block)
continue

if previous_was_markdown:
yield ""
previous_was_markdown = False

yield self.render_code_cell(block)

return "\n".join(
[
*self.render_preamble(document),
*render_blocks(),
]
).strip()

@abstractmethod
def render_preamble(self, document: MarkdownExportDocument) -> list[str]:
"""Render document-level metadata before the body.

Preamble syntax and metadata filtering are target-specific. A flavor
might use YAML frontmatter, a directive-based config block, no preamble
at all, or another target-native metadata surface.
"""

@abstractmethod
def render_markdown(self, block: MarkdownCellBlock) -> str:
"""Render markdown text without altering user-authored markdown."""

@abstractmethod
def render_code_cell(self, cell: CodeCellBlock) -> str:
"""Render an executable marimo cell.

Code cell syntax differs across targets. Some formats use fenced code
attributes, some use directive option lines, and others may use a
different executable-cell wrapper.
"""
Loading
Loading