diff --git a/marimo/_cli/export/commands.py b/marimo/_cli/export/commands.py index 598bc6ecc65..a14ecab459c 100644 --- a/marimo/_cli/export/commands.py +++ b/marimo/_cli/export/commands.py @@ -4,6 +4,7 @@ import asyncio import os import sys +from dataclasses import replace from pathlib import Path from typing import TYPE_CHECKING, Literal @@ -22,6 +23,11 @@ ) 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 @@ -29,7 +35,6 @@ from marimo._server.export import ( ExportResult, export_as_ipynb, - export_as_md, export_as_script, export_as_wasm, notebook_uses_slides_layout, @@ -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. " @@ -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. @@ -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( @@ -363,6 +405,12 @@ def export_callback(file_path: MarimoPath) -> ExportResult: type=bool, help=_sandbox_message, ) +@click.option( + "--flavor", + type=click.Choice(["pymdown", "qmd", "mystmd"]), + default=None, + help="Markdown flavor to export.", +) @click.option( "-f", "--force", @@ -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. @@ -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 diff --git a/marimo/_convert/converters.py b/marimo/_convert/converters.py index fae12fe94ba..564809273ce 100644 --- a/marimo/_convert/converters.py +++ b/marimo/_convert/converters.py @@ -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.""" @@ -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.""" diff --git a/marimo/_convert/markdown/flavor/__init__.py b/marimo/_convert/markdown/flavor/__init__.py new file mode 100644 index 00000000000..83a771397e6 --- /dev/null +++ b/marimo/_convert/markdown/flavor/__init__.py @@ -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", +] diff --git a/marimo/_convert/markdown/flavor/base.py b/marimo/_convert/markdown/flavor/base.py new file mode 100644 index 00000000000..d0ae9bb4f86 --- /dev/null +++ b/marimo/_convert/markdown/flavor/base.py @@ -0,0 +1,141 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, ClassVar, Literal, Protocol + +if TYPE_CHECKING: + from collections.abc import Iterator + +MarkdownFlavorName = Literal["pymdown", "qmd", "mystmd"] + + +def _escape_attribute(value: str) -> str: + return ( + value.replace("&", "&") + .replace('"', """) + .replace("<", "<") + .replace(">", ">") + ) + + +@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] + + +@dataclass +class MarkdownImportContext: + """Mutable state shared by markdown import dialects.""" + + metadata: dict[str, str] = field(default_factory=dict) + + +class MarkdownImportDialect(Protocol): + """Source markdown syntax adapter for the canonical importer.""" + + name: MarkdownFlavorName + + def matches(self, text: str, filepath: str | None) -> bool: + """Return whether this dialect should preprocess the markdown.""" + ... + + def preprocess( + self, lines: list[str], context: MarkdownImportContext + ) -> list[str]: + """Normalize source markdown before the canonical importer runs.""" + ... + + +class MarkdownFlavor(ABC): + """Markdown-family output flavor. + + The base renderer assembles a document from preamble, markdown blocks, and + code cells. Concrete flavors provide target-specific metadata and cell + syntax. + """ + + 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 can + use YAML frontmatter, a directive-based config block, or another + target-native metadata surface. + """ + + @abstractmethod + def render_markdown(self, block: MarkdownCellBlock) -> str: + """Render user-authored markdown text unchanged.""" + + @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. + """ diff --git a/marimo/_convert/markdown/flavor/mystmd.py b/marimo/_convert/markdown/flavor/mystmd.py new file mode 100644 index 00000000000..7f50cb12ad8 --- /dev/null +++ b/marimo/_convert/markdown/flavor/mystmd.py @@ -0,0 +1,400 @@ +"""mystmd markdown target flavor for marimo notebook exports. + +Marimo cells are emitted as mystmd directives: + +```{marimo} python +:hide-code: true + +x = 1 +``` +""" + +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from marimo import _loggers +from marimo._convert.markdown.flavor.base import ( + CodeCellBlock, + MarkdownCellBlock, + MarkdownExportDocument, + MarkdownFlavor, + MarkdownFlavorName, + MarkdownImportContext, + _escape_attribute, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +LOGGER = _loggers.marimo_logger() + +# Metadata emitted through the `{marimo-config}` directive. +_CONFIG_KEYS = {"header", "pyproject", "width"} +# marimo-specific metadata filtered before writing MyST frontmatter. +_MARIMO_METADATA_KEYS = {"width"} +# MyST marimo executable directive headers. +_MARIMO_DIRECTIVE_HEADER_RE = re.compile( + r"^[ ]{0,3}(?P`{3,})\{marimo\}" + r"(?:\s+(?P\w+))?\s*$" +) +# MyST marimo page-level configuration directive headers. +_MARIMO_CONFIG_HEADER_RE = re.compile( + r"^[ ]{0,3}(?P`{3,})\{marimo-config\}\s*$" +) +# Markdown backtick fences. Used to skip ordinary fenced examples before +# normalizing MyST marimo directives. +_FENCE_HEADER_RE = re.compile(r"^[ ]{0,3}(?P`{3,}).*$") +# MyST directive options, e.g. `:hide-code: true`. +_DIRECTIVE_OPTION_RE = re.compile(r"^:([A-Za-z0-9_-]+):(?:\s+(.*))?$") +# PEP 723 script metadata blocks embedded in exported notebook headers. +_SCRIPT_METADATA_RE = re.compile( + r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s" + r"(?P(^#(| .*)$\s)+)^# ///$" +) + + +class _MystmdMarkdownImportDialect: + """Normalize MyST marimo directives into canonical marimo markdown.""" + + name: MarkdownFlavorName = "mystmd" + + def matches(self, text: str, filepath: str | None) -> bool: + del filepath + return ( + _find_next_top_level_marimo_header(text.splitlines()) is not None + ) + + def preprocess( + self, lines: list[str], context: MarkdownImportContext + ) -> list[str]: + normalized: list[str] = [] + index = 0 + + while index < len(lines): + config_match = _MARIMO_CONFIG_HEADER_RE.match(lines[index]) + if config_match is not None: + closing_index = _find_closing_fence( + lines, index + 1, config_match.group("fence") + ) + if closing_index is None: + normalized.extend(lines[index:]) + break + + context.metadata.update( + _extract_config_metadata(lines[index + 1 : closing_index]) + ) + index = closing_index + 1 + continue + + match = _MARIMO_DIRECTIVE_HEADER_RE.match(lines[index]) + if match is None: + fence_match = _FENCE_HEADER_RE.match(lines[index]) + if fence_match is None: + normalized.append(lines[index]) + index += 1 + continue + + closing_index = _find_closing_fence( + lines, index + 1, fence_match.group("fence") + ) + if closing_index is None: + normalized.extend(lines[index:]) + break + normalized.extend(lines[index : closing_index + 1]) + index = closing_index + 1 + continue + + closing_index = _find_closing_fence( + lines, index + 1, match.group("fence") + ) + if closing_index is None: + normalized.extend(lines[index:]) + break + + options, body_lines = _extract_directive_options( + lines[index + 1 : closing_index] + ) + normalized.append(_canonical_code_fence_head(match, options)) + normalized.extend(body_lines) + normalized.append(match.group("fence")) + index = closing_index + 1 + + return normalized + + +class MystmdMarkdownFlavor(MarkdownFlavor): + """Render marimo notebooks as mystmd markdown. + + mystmd uses directive option lines for cell metadata. Page-level execution + metadata is emitted through a `{marimo-config}` directive. + """ + + name = "mystmd" + + def prepare_metadata( + self, metadata: dict[str, str | list[str]] + ) -> dict[str, str | list[str]]: + return metadata + + def render_preamble(self, document: MarkdownExportDocument) -> list[str]: + metadata = self.prepare_metadata(document.metadata) + return [ + *_mystmd_frontmatter(metadata), + *_mystmd_config(metadata, document.header), + ] + + def render_markdown(self, block: MarkdownCellBlock) -> str: + return block.text + + def render_code_cell(self, cell: CodeCellBlock) -> str: + code_lines = cell.source.splitlines() + code = "\n".join(code_lines) + guard = _fence_guard_for(code) + + return "\n".join( + [ + f"{guard}{{marimo}} {cell.language}", + *[ + f":{_mystmd_option_name(key)}: {value}" + for key, value in cell.options.items() + ], + *([""] if cell.options else []), + code, + guard, + "", + ] + ) + + +def _mystmd_frontmatter( + metadata: Mapping[str, object], +) -> list[str]: + from marimo._utils import yaml + + filtered = { + key: value + for key, value in metadata.items() + if key not in _CONFIG_KEYS + and key not in _MARIMO_METADATA_KEYS + and value is not None + and value != "" + and value != [] + } + if not filtered: + return [] + + body = yaml.marimo_compat_dump(filtered, sort_keys=False).strip() + return [ + "---", + body, + "---", + "", + ] + + +def _mystmd_config( + metadata: Mapping[str, object], document_header: str | None +) -> list[str]: + from marimo._utils import yaml + + header = str(metadata.get("header") or document_header or "").strip() + header, header_pyproject = _split_script_metadata(header) + pyproject = str(metadata.get("pyproject") or header_pyproject).strip() + width = metadata.get("width") + config = { + key: value + for key, value in {"header": header, "pyproject": pyproject}.items() + if value + } + if isinstance(width, str) and width: + config["width"] = width + if not config: + return [] + + body = yaml.marimo_compat_dump(config, sort_keys=False).strip() + guard = _fence_guard_for(f"---\n{body}\n---") + return [ + f"{guard}{{marimo-config}}", + "---", + body, + "---", + guard, + "", + ] + + +def _split_script_metadata(header: str) -> tuple[str, str]: + pyproject = "" + + def replace(match: re.Match[str]) -> str: + nonlocal pyproject + if match.group("type") != "script": + return match.group(0) + if not pyproject: + pyproject = _uncomment_script_metadata( + match.group("content") + ).strip() + return "" + + return _SCRIPT_METADATA_RE.sub(replace, header).strip(), pyproject + + +def _uncomment_script_metadata(content: str) -> str: + return "".join( + line[2:] if line.startswith("# ") else line[1:] + for line in content.splitlines(keepends=True) + ) + + +def _mystmd_option_name(key: str) -> str: + return key.replace("_", "-") + + +def _is_marimo_header(line: str) -> bool: + return bool( + _MARIMO_DIRECTIVE_HEADER_RE.match(line) + or _MARIMO_CONFIG_HEADER_RE.match(line) + ) + + +def _fence_guard_for(body: str) -> str: + guard = "```" + while guard in body: + guard += "`" + return guard + + +def _find_next_top_level_marimo_header(lines: list[str]) -> int | None: + index = 0 + while index < len(lines): + if _is_marimo_header(lines[index]): + return index + + fence_match = _FENCE_HEADER_RE.match(lines[index]) + if fence_match is None: + index += 1 + continue + + closing_index = _find_closing_fence( + lines, index + 1, fence_match.group("fence") + ) + if closing_index is None: + return None + index = closing_index + 1 + + return None + + +def _is_closing_fence(line: str, opening_fence: str) -> bool: + stripped = line.strip() + return len(stripped) >= len(opening_fence) and set(stripped) == {"`"} + + +def _find_closing_fence( + lines: list[str], start: int, opening_fence: str +) -> int | None: + for index in range(start, len(lines)): + if _is_closing_fence(lines[index], opening_fence): + return index + return None + + +def _extract_directive_options( + lines: list[str], +) -> tuple[dict[str, str], list[str]]: + options: dict[str, str] = {} + body_start = 0 + + for index, line in enumerate(lines): + match = _DIRECTIVE_OPTION_RE.match(line) + if match is None: + break + options[match.group(1).replace("-", "_")] = match.group(2) or "true" + body_start = index + 1 + + if body_start and body_start < len(lines) and lines[body_start] == "": + body_start += 1 + + return options, lines[body_start:] + + +def _canonical_code_fence_head( + match: re.Match[str], options: dict[str, str] +) -> str: + attribute_str = "".join( + f' {key}="{_escape_attribute(value)}"' + for key, value in options.items() + ) + return "{fence}{language} {{.marimo{attributes}}}".format( + fence=match.group("fence"), + language=match.group("language") or "python", + attributes=attribute_str, + ) + + +def extract_mystmd_config_metadata(markdown: str) -> dict[str, str]: + lines = markdown.splitlines() + metadata: dict[str, str] = {} + index = 0 + + while index < len(lines): + next_index = _find_next_top_level_marimo_header(lines[index:]) + if next_index is None: + break + index += next_index + + config_match = _MARIMO_CONFIG_HEADER_RE.match(lines[index]) + if config_match is None: + directive_match = _MARIMO_DIRECTIVE_HEADER_RE.match(lines[index]) + if directive_match is None: + index += 1 + continue + + closing_index = _find_closing_fence( + lines, index + 1, directive_match.group("fence") + ) + if closing_index is None: + break + index = closing_index + 1 + continue + + closing_index = _find_closing_fence( + lines, index + 1, config_match.group("fence") + ) + if closing_index is None: + break + + metadata.update( + _extract_config_metadata(lines[index + 1 : closing_index]) + ) + index = closing_index + 1 + + return metadata + + +def _extract_config_metadata(lines: list[str]) -> dict[str, str]: + from marimo._utils import yaml + + if lines and lines[0] == "---": + for index, line in enumerate(lines[1:], start=1): + if line == "---": + lines = lines[1:index] + break + + try: + metadata = yaml.load("\n".join(lines)) + except yaml.YAMLError: + LOGGER.warning("Error parsing marimo-config YAML. Ignoring config.") + return {} + + if not isinstance(metadata, dict): + return {} + + return { + key: value + for key, value in metadata.items() + if key in _CONFIG_KEYS and isinstance(value, str) + } diff --git a/marimo/_convert/markdown/flavor/pymdown.py b/marimo/_convert/markdown/flavor/pymdown.py new file mode 100644 index 00000000000..5bdf66d2151 --- /dev/null +++ b/marimo/_convert/markdown/flavor/pymdown.py @@ -0,0 +1,84 @@ +"""PyMdown markdown target flavor.""" + +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from marimo._convert.markdown.flavor.base import ( + CodeCellBlock, + MarkdownCellBlock, + MarkdownExportDocument, + MarkdownFlavor, + _escape_attribute, +) +from marimo._dependencies.dependencies import DependencyManager + + +class PymdownMarkdownFlavor(MarkdownFlavor): + """Render marimo Markdown with PyMdown syntax. + + This flavor emits YAML frontmatter and fenced marimo code cells while + preserving markdown cells as authored. + """ + + name = "pymdown" + + def prepare_metadata( + self, metadata: dict[str, str | list[str]] + ) -> dict[str, str | list[str]]: + return metadata + + def render_preamble(self, document: MarkdownExportDocument) -> list[str]: + from marimo._utils import yaml + + metadata = self.prepare_metadata(document.metadata) + header = yaml.marimo_compat_dump( + { + key: value + for key, value in metadata.items() + if value is not None and value != "" and value != [] + }, + sort_keys=False, + ) + return ["---", header.strip(), "---", ""] + + def render_markdown(self, block: MarkdownCellBlock) -> str: + return block.text + + def render_code_cell(self, cell: CodeCellBlock) -> str: + return self._render_code_fence( + cell.source, + {"language": cell.language, **cell.options}, + ) + + def _code_fence_head( + self, guard: str, language: str, attribute_str: str + ) -> str: + # Compatible with GitHub syntax highlighting: + # ```python {.marimo attr=...} + if DependencyManager.new_superfences.has_required_version(quiet=True): + return f"{guard}{language} {{.marimo{attribute_str}}}" + + # ```{.python.marimo attr=...} + return f"{guard}{{.{language}.marimo{attribute_str}}}" + + def _render_code_fence( + self, + code: str, + attributes: dict[str, str] | None = None, + ) -> str: + attributes = dict(attributes or {}) + language = attributes.pop("language", "python") + attribute_str = " ".join( + [""] + + [ + f'{key}="{_escape_attribute(str(value))}"' + for key, value in attributes.items() + ] + ) + guard = "```" + while guard in code: + guard += "`" + + head = self._code_fence_head(guard, language, attribute_str) + parts = [head, code, guard, ""] + return "\n".join(parts) diff --git a/marimo/_convert/markdown/flavor/qmd.py b/marimo/_convert/markdown/flavor/qmd.py new file mode 100644 index 00000000000..59b2addb470 --- /dev/null +++ b/marimo/_convert/markdown/flavor/qmd.py @@ -0,0 +1,79 @@ +"""Quarto markdown target flavor.""" + +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from marimo._convert.markdown.flavor.base import ( + CodeCellBlock, + MarkdownCellBlock, + MarkdownExportDocument, + MarkdownFlavor, + _escape_attribute, +) + + +class QmdMarkdownFlavor(MarkdownFlavor): + """Render marimo exports as Quarto-native markdown. + + This flavor emits Quarto-style executable marimo code fences while + preserving markdown cells as authored. + """ + + name = "qmd" + + def prepare_metadata( + self, metadata: dict[str, str | list[str]] + ) -> dict[str, str | list[str]]: + return metadata.copy() + + def render_preamble(self, document: MarkdownExportDocument) -> list[str]: + from marimo._utils import yaml + + metadata = self.prepare_metadata(document.metadata) + header = yaml.marimo_compat_dump( + { + key: value + for key, value in metadata.items() + if value is not None and value != "" and value != [] + }, + sort_keys=False, + ) + return ["---", header.strip(), "---", ""] + + def render_markdown(self, block: MarkdownCellBlock) -> str: + return block.text + + def render_code_cell(self, cell: CodeCellBlock) -> str: + return self._render_code_fence( + cell.source, + {"language": cell.language, **cell.options}, + ) + + def _code_fence_head( + self, guard: str, language: str, attribute_str: str + ) -> str: + # Quarto executable syntax with claimsLanguage() support: + # ```{marimo .python attr=...} + return f"{guard}{{marimo .{language}{attribute_str}}}" + + def _render_code_fence( + self, + code: str, + attributes: dict[str, str] | None = None, + ) -> str: + attributes = dict(attributes or {}) + language = attributes.pop("language", "python") + attribute_str = " ".join( + [""] + + [ + f'{key}="{_escape_attribute(str(value))}"' + for key, value in attributes.items() + ] + ) + guard = "```" + while guard in code: + guard += "`" + + head = self._code_fence_head(guard, language, attribute_str) + parts = [head, code, guard, ""] + return "\n".join(parts) diff --git a/marimo/_convert/markdown/from_ir.py b/marimo/_convert/markdown/from_ir.py index 35c9f762f3d..951a629de3b 100644 --- a/marimo/_convert/markdown/from_ir.py +++ b/marimo/_convert/markdown/from_ir.py @@ -7,11 +7,18 @@ import textwrap from typing import TYPE_CHECKING -from marimo import _loggers from marimo._ast import codegen from marimo._ast.compiler import const_or_id from marimo._ast.names import is_internal_cell_name from marimo._convert.common.format import get_markdown_from_cell +from marimo._convert.markdown.flavor import normalize_markdown_flavor +from marimo._convert.markdown.flavor.base import ( + CodeCellBlock, + MarkdownCellBlock, + MarkdownExportDocument, + MarkdownFlavor, + MarkdownFlavorName, +) from marimo._schemas.serialization import NotebookSerializationV1 from marimo._types.ids import CellId_t from marimo._version import __version__ @@ -20,22 +27,29 @@ from marimo._ast.cell import CellImpl from marimo._ast.visitor import Language -LOGGER = _loggers.marimo_logger() - def convert_from_ir_to_markdown( notebook: NotebookSerializationV1, filename: str | None = None, + flavor: MarkdownFlavor | MarkdownFlavorName | None = None, ) -> str: + filename = filename or notebook.filename or "notebook.md" + markdown_flavor = normalize_markdown_flavor(flavor, filename=filename) + document = _notebook_to_markdown_export_document(notebook, filename) + return markdown_flavor.render_document(document) + + +def _notebook_to_markdown_export_document( + notebook: NotebookSerializationV1, + filename: str, +) -> MarkdownExportDocument: from marimo._ast.app_config import _AppConfig from marimo._ast.compiler import compile_cell from marimo._convert.markdown.to_ir import ( - formatted_code_block, is_sanitized_markdown, ) from marimo._utils import yaml - filename = filename or notebook.filename or "notebook.md" app_title = notebook.app.options.get("app_title", None) if not app_title: app_title = _format_filename_title(filename) @@ -62,6 +76,8 @@ def convert_from_ir_to_markdown( } ) + header: str | None = None + # Recover frontmatter metadata from header if notebook.header and notebook.header.value: try: @@ -73,34 +89,14 @@ def convert_from_ir_to_markdown( metadata = _recovered except (yaml.YAMLError, AssertionError): # Not valid YAML dict — treat as script preamble - metadata["header"] = notebook.header.value.strip() - - # Add the expected qmd filter to the metadata. - is_qmd = filename.endswith(".qmd") - if is_qmd: - if "filters" not in metadata: - metadata["filters"] = [] - if "marimo" not in str(metadata["filters"]): - if isinstance(metadata["filters"], str): - metadata["filters"] = metadata["filters"].split(",") - if isinstance(metadata["filters"], list): - metadata["filters"].append("marimo-team/marimo") - else: - LOGGER.warning( - "Unexpected type for filters: %s", - type(metadata["filters"]), - ) + header = notebook.header.value.strip() + metadata["header"] = header - header = yaml.marimo_compat_dump( - { - k: v - for k, v in metadata.items() - if v is not None and v != "" and v != [] - }, - sort_keys=False, + document = MarkdownExportDocument( + metadata=metadata, + header=header, + blocks=[], ) - document = ["---", header.strip(), "---", ""] - previous_was_markdown = False for cell in notebook.cells: code = cell.code @@ -137,11 +133,7 @@ def convert_from_ir_to_markdown( markdown = get_markdown_from_cell(cell_impl, code) # Unsanitized markdown is forced to code. if markdown and is_sanitized_markdown(markdown): - # Use blank HTML comment to separate markdown codeblocks - if previous_was_markdown: - document.append("") - previous_was_markdown = True - document.append(markdown) + document.blocks.append(MarkdownCellBlock(markdown)) continue # In which case we need to format it like our python blocks. elif cell_impl.markdown: @@ -172,18 +164,26 @@ def convert_from_ir_to_markdown( # Dedent and strip code to prevent whitespace accumulation on roundtrips code = textwrap.dedent(code).strip() - # Add a blank line between markdown and code - if previous_was_markdown: - document.append("") - previous_was_markdown = False - document.append(formatted_code_block(code, attributes, is_qmd=is_qmd)) + language = attributes.pop("language", "python") + document.blocks.append( + CodeCellBlock( + source=code, + language=language, + options=attributes, + ) + ) - return "\n".join(document).strip() + return document def _format_filename_title(filename: str) -> str: basename = os.path.basename(filename) - name, _ext = os.path.splitext(basename) + for suffix in (".myst.md", ".markdown", ".qmd", ".md", ".py"): + if basename.endswith(suffix): + name = basename[: -len(suffix)] + break + else: + name, _ext = os.path.splitext(basename) title = re.sub("[-_]", " ", name) return title.title() diff --git a/marimo/_convert/markdown/to_ir.py b/marimo/_convert/markdown/to_ir.py index aeb6f8fd931..71c174fbeb9 100644 --- a/marimo/_convert/markdown/to_ir.py +++ b/marimo/_convert/markdown/to_ir.py @@ -1,6 +1,7 @@ # Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import html import re from dataclasses import dataclass from typing import ( @@ -33,6 +34,16 @@ from marimo._ast.cell import CellConfig from marimo._ast.names import DEFAULT_CELL_NAME from marimo._convert.common.format import markdown_to_marimo, sql_to_marimo +from marimo._convert.markdown.flavor import ( + _markdown_import_dialects, + default_markdown_flavor, +) +from marimo._convert.markdown.flavor.base import ( + CodeCellBlock, + MarkdownFlavor, + MarkdownImportContext, + MarkdownImportDialect, +) from marimo._dependencies.dependencies import DependencyManager from marimo._schemas.serialization import ( AppInstantiation, @@ -42,7 +53,7 @@ ) if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Sequence LOGGER = _loggers.marimo_logger() @@ -70,7 +81,10 @@ def extract_attribs( # .python.marimo disabled="true" inner = fence_start.group("attrs") if inner: - return dict(re.findall(r'(\w+)="([^"]*)"', inner)) + return { + key: html.unescape(value) + for key, value in re.findall(r'(\w+)="([^"]*)"', inner) + } return {} @@ -89,37 +103,25 @@ def _get_language(text: str) -> str: match = RE_NESTED_FENCE_START.match(header) if match and match.group("lang"): return str(match.group("lang")) + if match and match.group("attrs"): + attributes = str(match.group("attrs")) + for language in ("python", "sql", "markdown"): + if re.search(rf"(?:^|[.\s]){language}(?:[.\s]|$)", attributes): + return language return "python" def formatted_code_block( code: str, attributes: dict[str, str] | None = None, - is_qmd: bool = False, + flavor: MarkdownFlavor | None = None, ) -> str: """Wraps code in a fenced code block with marimo attributes.""" - if attributes is None: - attributes = {} + if flavor is None: + flavor = default_markdown_flavor() + attributes = dict(attributes or {}) language = attributes.pop("language", "python") - attribute_str = " ".join( - [""] + [f'{key}="{value}"' for key, value in attributes.items()] - ) - guard = "```" - while guard in code: - guard += "`" - - # Quarto executable syntax with claimsLanguage() support - # ```{marimo .python attr=...} - if is_qmd: - head = f"""{guard}{{marimo .{language}{attribute_str}}}""" - # Compatible with GitHub syntax highlighting - # ```python {.marimo attr=...} - elif DependencyManager.new_superfences.has_required_version(quiet=True): - head = f"""{guard}{language} {{.marimo{attribute_str}}}""" - # ```{.python.marimo attr=...} - else: - head = f"""{guard}{{.{language}.marimo{attribute_str}}}""" - return f"{head}\n{code}\n{guard}\n" + return flavor.render_code_cell(CodeCellBlock(code, language, attributes)) def app_config_from_root(root: Element) -> dict[str, Any]: @@ -302,12 +304,14 @@ def __init__( self, *args: Any, output_format: ConvertKeys = "marimo-ir", + import_dialects: Sequence[MarkdownImportDialect] = (), **kwargs: Any, ) -> None: super().__init__( *args, output_format=cast(Any, output_format), **kwargs ) self.meta = {} + import_context = MarkdownImportContext() # Build here opposed to the parent class since there is intermediate # logic after the parser is built, and it is more clear here what is # registered. @@ -318,6 +322,14 @@ def __init__( self.preprocessors.register( FrontMatterPreprocessor(self), "frontmatter", 100 ) + if import_dialects: + self.preprocessors.register( + MarkdownImportDialectPreprocessor( + self, import_dialects, import_context + ), + "markdown-import-dialects", + 99, + ) fences_ext = SuperFencesCodeExtension() fences_ext.extendMarkdown(self) # TODO: Consider adding the admonition extension, and integrating it @@ -383,6 +395,27 @@ def run(self, lines: list[str]) -> list[str]: return doc.split("\n") +class MarkdownImportDialectPreprocessor(Preprocessor): + """Normalize dialect-specific markdown before SuperFences parses it.""" + + def __init__( + self, + md: MarimoMdParser, + import_dialects: Sequence[MarkdownImportDialect], + context: MarkdownImportContext, + ) -> None: + super().__init__(md) + self.md: MarimoMdParser = md + self.import_dialects = import_dialects + self.context = context + + def run(self, lines: list[str]) -> list[str]: + for dialect in self.import_dialects: + lines = dialect.preprocess(lines, self.context) + self.md.meta.update(self.context.metadata) + return lines + + class SanitizeProcessor(Preprocessor): """Prevent unintended executable code block injection. @@ -508,7 +541,10 @@ def convert_from_md_to_marimo_ir( return NotebookSerializationV1( app=AppInstantiation(options={}), filename=filepath ) - notebook = MarimoMdParser(output_format="marimo-ir").convert(text) + notebook = MarimoMdParser( + output_format="marimo-ir", + import_dialects=_markdown_import_dialects(text, filepath), + ).convert(text) assert isinstance(notebook, NotebookSerializationV1) return NotebookSerializationV1( app=notebook.app, diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 1500f79f70c..84f93c58d50 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -19,6 +19,10 @@ make_download_headers, ) from marimo._convert.markdown import convert_from_ir_to_markdown +from marimo._convert.markdown.flavor import ( + markdown_output_filename, + normalize_markdown_flavor, +) from marimo._convert.script import convert_from_ir_to_script from marimo._dependencies.dependencies import DependencyManager from marimo._messaging.msgspec_encoder import asdict @@ -43,6 +47,8 @@ if TYPE_CHECKING: from starlette.requests import Request + from marimo._schemas.serialization import NotebookSerializationV1 + LOGGER = _loggers.marimo_logger() # Router for export endpoints @@ -51,6 +57,22 @@ auto_exporter = AutoExporter() +def _export_markdown( + notebook: NotebookSerializationV1, filename: str | None +) -> tuple[str, str]: + export_filename = filename or notebook.filename + markdown_flavor = normalize_markdown_flavor( + None, filename=export_filename or "notebook.md" + ) + markdown = convert_from_ir_to_markdown( + notebook, filename=export_filename, flavor=markdown_flavor + ) + return ( + markdown, + markdown_output_filename(export_filename, markdown_flavor), + ) + + @router.post("/html") @requires("read") async def export_as_html( @@ -277,12 +299,11 @@ async def export_as_markdown( detail="File must be saved before downloading", ) - markdown = convert_from_ir_to_markdown(app_file_manager.app.to_ir()) + markdown, download_filename = _export_markdown( + app_file_manager.app.to_ir(), app_file_manager.filename + ) if body.download: - download_filename = get_download_filename( - app_file_manager.filename, "md" - ) headers = make_download_headers(download_filename) else: headers = {} diff --git a/marimo/_server/export/__init__.py b/marimo/_server/export/__init__.py index 18473723924..b0f729281ee 100644 --- a/marimo/_server/export/__init__.py +++ b/marimo/_server/export/__init__.py @@ -19,6 +19,10 @@ ) from marimo._convert.common.filename import get_download_filename from marimo._convert.converters import MarimoConvert +from marimo._convert.markdown.flavor import ( + markdown_output_filename, + normalize_markdown_flavor, +) from marimo._messaging.cell_output import CellChannel, CellOutput from marimo._messaging.errors import Error, is_unexpected_error from marimo._messaging.notification import ( @@ -104,9 +108,13 @@ def export_as_script(path: MarimoPath) -> ExportResult: def export_as_md(path: MarimoPath) -> ExportResult: ir = _as_ir(path) + filename = ir.filename or path.short_name + markdown_flavor = normalize_markdown_flavor(None, filename=filename) return ExportResult( - contents=MarimoConvert.from_ir(ir).to_markdown(), - download_filename=get_download_filename(path.short_name, "md"), + contents=MarimoConvert.from_ir(ir).to_markdown( + filename=filename, flavor=markdown_flavor + ), + download_filename=markdown_output_filename(filename, markdown_flavor), did_error=False, ) diff --git a/marimo/_session/notebook/serializer.py b/marimo/_session/notebook/serializer.py index 6db15c964c7..c7fe68d0108 100644 --- a/marimo/_session/notebook/serializer.py +++ b/marimo/_session/notebook/serializer.py @@ -105,18 +105,23 @@ def extract_header(self, path: Path) -> str | None: """Extract full frontmatter metadata from Markdown file as YAML. Unlike Python files where only the script preamble matters, markdown - frontmatter can carry arbitrary metadata (author, description, tags, - etc.) that must survive through the save lifecycle. Return the full - frontmatter as YAML so _save_file() preserves it all. + frontmatter and MyST marimo-config directives can carry metadata that + must survive through the save lifecycle. Return the full metadata as + YAML so _save_file() preserves it all. """ + from marimo._convert.markdown.flavor.mystmd import ( + extract_mystmd_config_metadata, + ) from marimo._convert.markdown.to_ir import extract_frontmatter from marimo._utils import yaml markdown = path.read_text(encoding="utf-8") frontmatter, _ = extract_frontmatter(markdown) - if not frontmatter: + metadata = dict(frontmatter or {}) + metadata.update(extract_mystmd_config_metadata(markdown)) + if not metadata: return None - return yaml.dump(frontmatter, sort_keys=False) + return yaml.dump(metadata, sort_keys=False) # Default format handlers diff --git a/marimo/_tutorials/markdown_format.md b/marimo/_tutorials/markdown_format.md index d633feae2e6..c7c675fa5e4 100644 --- a/marimo/_tutorials/markdown_format.md +++ b/marimo/_tutorials/markdown_format.md @@ -135,14 +135,10 @@ print("This code cell has a syntax error" and on notebook save, will annotate the cell for you: ````md -```python {.marimo unparseable="true"} -print("This code cell has a syntax error" -``` -```` - ```python {.marimo unparsable="true"} print("This code cell has a syntax error" ``` +```` ## Limitations of the markdown format diff --git a/tests/_cli/test_cli_export.py b/tests/_cli/test_cli_export.py index 632fe372d42..afa853bee74 100644 --- a/tests/_cli/test_cli_export.py +++ b/tests/_cli/test_cli_export.py @@ -702,6 +702,78 @@ def test_export_markdown_broken(temp_unparsable_marimo_file: str) -> None: _assert_success(p) snapshot(_get_snapshot_path("md", "broken"), p.output) + @staticmethod + def test_export_markdown_with_flavor(temp_marimo_file: str) -> None: + p = _run_export("md", temp_marimo_file, "--flavor", "qmd") + _assert_success(p) + assert "```{marimo .python" in p.output + + @staticmethod + def test_export_markdown_infers_qmd_from_output( + temp_marimo_file: str, tmp_path: Path + ) -> None: + output = tmp_path / "output-target.qmd" + + p = _run_export("md", temp_marimo_file, "--output", str(output)) + + _assert_success(p) + contents = output.read_text() + assert "title: Notebook" in contents + assert "title: Output Target" not in contents + assert "```{marimo .python" in contents + + @staticmethod + def test_export_markdown_infers_mystmd_from_output( + temp_marimo_file: str, tmp_path: Path + ) -> None: + output = tmp_path / "notebook.myst.md" + + p = _run_export("md", temp_marimo_file, "--output", str(output)) + + _assert_success(p) + assert "```{marimo} python" in output.read_text() + + @staticmethod + def test_export_markdown_stdout_uses_default_flavor( + temp_marimo_file: str, + ) -> None: + p = _run_export("md", temp_marimo_file) + + _assert_success(p) + assert "```{marimo .python" not in p.output + assert "```{marimo} python" not in p.output + assert ( + "```python {.marimo" in p.output + or "```{.python.marimo" in p.output + ) + + @staticmethod + def test_export_markdown_help_documents_stdout_inference() -> None: + p = _runner.invoke(main, ["export", "md", "--help"]) + + _assert_success(p) + assert "shell redirection is not inspected" in " ".join( + p.output.split() + ) + + @staticmethod + def test_export_markdown_explicit_flavor_overrides_output( + temp_marimo_file: str, tmp_path: Path + ) -> None: + output = tmp_path / "notebook.qmd" + + p = _run_export( + "md", + temp_marimo_file, + "--output", + str(output), + "--flavor", + "pymdown", + ) + + _assert_success(p) + assert "```{marimo .python" not in output.read_text() + @staticmethod def test_export_markdown_with_errors( temp_marimo_file_with_errors: str, diff --git a/tests/_convert/markdown/test_markdown_conversion.py b/tests/_convert/markdown/test_markdown_conversion.py index 404ba1fee76..05d122957aa 100644 --- a/tests/_convert/markdown/test_markdown_conversion.py +++ b/tests/_convert/markdown/test_markdown_conversion.py @@ -12,7 +12,16 @@ from marimo._ast.app import InternalApp from marimo._ast.load import load_notebook_ir from marimo._convert.converters import MarimoConvert -from marimo._convert.markdown.to_ir import convert_from_md_to_marimo_ir +from marimo._convert.markdown.from_ir import convert_from_ir_to_markdown +from marimo._convert.markdown.to_ir import ( + convert_from_md_to_marimo_ir, +) +from marimo._schemas.serialization import ( + AppInstantiation, + CellDef, + Header, + NotebookSerializationV1, +) # Just a handful of scripts to test from marimo._tutorials import dataflow, for_jupyter_users, sql @@ -115,6 +124,318 @@ def test_markdown_frontmatter() -> None: assert app.cell_manager.cell_data_at(ids[1]).config.hide_code is False +def test_mystmd_marimo_directives() -> None: + script = dedent( + remove_empty_lines( + """ + --- + title: "My Title" + width: full + header: | + import os + pyproject: | + dependencies = ["polars"] + --- + + # Notebook + + ````{marimo} python + :hide-code: true + + print("Hello, World!") + ```` + + ```{marimo} sql + :query: result + :engine: engines["primary"] + + SELECT 1 + ``` + """ + ) + ) + + notebook_ir = convert_from_md_to_marimo_ir(script) + app = InternalApp(load_notebook_ir(notebook_ir)) + + assert app.config.app_title == "My Title" + assert app.config.width == "full" + assert notebook_ir.header is not None + assert "import os" in notebook_ir.header.value + assert 'dependencies = ["polars"]' in notebook_ir.header.value + + ids = list(app.cell_manager.cell_ids()) + assert len(ids) == 3 + assert app.cell_manager.cell_data_at(ids[0]).code.startswith("mo.md") + assert app.cell_manager.cell_data_at(ids[1]).config.hide_code is True + assert ( + app.cell_manager.cell_data_at(ids[1]).code == 'print("Hello, World!")' + ) + assert "SELECT 1" in app.cell_manager.cell_data_at(ids[2]).code + assert "result" in app.cell_manager.cell_data_at(ids[2]).code + assert ( + 'engine=engines["primary"]' + in app.cell_manager.cell_data_at(ids[2]).code + ) + + +def test_mystmd_marimo_config_directive() -> None: + config_lines = ( + "```{marimo-config}", + "---", + "header: |-", + " import os", + "pyproject: |-", + ' dependencies = ["polars"]', + "---", + "````", + ) + script_lines = ( + *config_lines, + "", + "# Notebook", + "", + "```{marimo} python", + "x = 1", + "```", + ) + script = "\n".join(script_lines) + + notebook_ir = convert_from_md_to_marimo_ir(script) + app = InternalApp(load_notebook_ir(notebook_ir)) + + assert notebook_ir.header is not None + assert "import os" in notebook_ir.header.value + assert 'dependencies = ["polars"]' in notebook_ir.header.value + + ids = list(app.cell_manager.cell_ids()) + assert len(ids) == 2 + assert app.cell_manager.cell_data_at(ids[0]).code.startswith("mo.md") + assert app.cell_manager.cell_data_at(ids[1]).code == "x = 1" + + +def test_mystmd_marimo_config_directive_only() -> None: + script_lines = ( + "```{marimo-config}", + "---", + "header: |-", + " import os", + "pyproject: |-", + ' dependencies = ["polars"]', + "---", + "```", + ) + script = "\n".join(script_lines) + + notebook_ir = convert_from_md_to_marimo_ir(script) + + assert notebook_ir.cells == [] + assert notebook_ir.header is not None + assert "import os" in notebook_ir.header.value + assert 'dependencies = ["polars"]' in notebook_ir.header.value + + +def test_mystmd_indented_marimo_directives() -> None: + script_lines = ( + " ```{marimo-config}", + "---", + "header: import os", + "width: full", + "---", + " ```", + "", + " ```{marimo} python", + "x = 1", + " ```", + ) + notebook_ir = convert_from_md_to_marimo_ir("\n".join(script_lines)) + app = InternalApp(load_notebook_ir(notebook_ir)) + + assert app.config.width == "full" + assert notebook_ir.header is not None + assert "import os" in notebook_ir.header.value + + ids = list(app.cell_manager.cell_ids()) + assert len(ids) == 1 + assert app.cell_manager.cell_data_at(ids[0]).code == "x = 1" + + +def test_mystmd_literal_directives_in_fenced_example() -> None: + script_lines = ( + "````markdown", + "```{marimo-config}", + "---", + "width: full", + "---", + "```", + "", + "```{marimo} python", + "x = 1", + "```", + "````", + ) + + notebook_ir = convert_from_md_to_marimo_ir("\n".join(script_lines)) + + assert notebook_ir.header is None + assert "width" not in notebook_ir.app.options + assert len(notebook_ir.cells) == 1 + assert "{marimo-config}" in notebook_ir.cells[0].code + assert "{marimo} python" in notebook_ir.cells[0].code + assert "x = 1" in notebook_ir.cells[0].code + + +def test_mystmd_marimo_config_keeps_indented_yaml_delimiters() -> None: + from marimo._utils import yaml + + script_lines = ( + "```{marimo-config}", + "---", + "header: |-", + " before", + " ---", + " after", + "---", + "```", + ) + + notebook_ir = convert_from_md_to_marimo_ir("\n".join(script_lines)) + + assert notebook_ir.header is not None + assert ( + yaml.load(notebook_ir.header.value)["header"] == "before\n---\nafter" + ) + + +def test_mystmd_exported_width_round_trips() -> None: + notebook = NotebookSerializationV1( + app=AppInstantiation(options={"width": "full"}), + cells=[CellDef(name="__", code="x = 1", options={})], + filename="notebook.myst.md", + ) + + markdown = convert_from_ir_to_markdown( + notebook, filename="notebook.myst.md", flavor="mystmd" + ) + round_tripped = convert_from_md_to_marimo_ir(markdown) + + assert "```{marimo-config}" in markdown + assert "width: full" in markdown + assert round_tripped.app.options["width"] == "full" + + +def test_mystmd_marimo_config_directive_reexports() -> None: + script_lines = ( + "```{marimo-config}", + "---", + "header: |-", + " import os", + "pyproject: |-", + ' dependencies = ["polars"]', + "---", + "```", + "", + "```{marimo} python", + "x = 1", + "```", + ) + notebook_ir = convert_from_md_to_marimo_ir("\n".join(script_lines)) + + markdown = convert_from_ir_to_markdown( + notebook_ir, filename="notebook.myst.md", flavor="mystmd" + ) + + assert "```{marimo-config}" in markdown + assert "import os" in markdown + assert 'dependencies = ["polars"]' in markdown + assert "```{marimo} python\nx = 1\n```" in markdown + + +def test_mystmd_exported_config_directive_round_trips() -> None: + header_lines = ( + "header: |-", + " import os", + "pyproject: |-", + ' dependencies = ["polars"]', + ) + notebook = NotebookSerializationV1( + app=AppInstantiation(options={}), + cells=[CellDef(name="__", code="x = 1", options={})], + header=Header(value="\n".join(header_lines)), + filename="notebook.py", + ) + + markdown = convert_from_ir_to_markdown( + notebook, filename="notebook.myst.md", flavor="mystmd" + ) + round_tripped = convert_from_md_to_marimo_ir(markdown) + + assert "```{marimo-config}" in markdown + assert len(round_tripped.cells) == 1 + assert round_tripped.cells[0].code == "x = 1" + assert round_tripped.header is not None + assert "import os" in round_tripped.header.value + assert 'dependencies = ["polars"]' in round_tripped.header.value + + +def test_mystmd_exported_config_uses_longer_fence_for_backticks() -> None: + header = '"""\n# Header\n\n```\ninside\n```\n"""' + notebook = NotebookSerializationV1( + app=AppInstantiation(options={}), + cells=[CellDef(name="__", code="x = 1", options={})], + header=Header(value=header), + filename="notebook.py", + ) + + markdown = convert_from_ir_to_markdown( + notebook, filename="notebook.myst.md", flavor="mystmd" + ) + round_tripped = convert_from_md_to_marimo_ir(markdown) + + assert "````{marimo-config}" in markdown + assert len(round_tripped.cells) == 1 + assert round_tripped.cells[0].code == "x = 1" + assert round_tripped.header is not None + assert "# Header" in round_tripped.header.value + assert "inside" in round_tripped.header.value + + +def test_mystmd_empty_python_cells_round_trip() -> None: + notebook = NotebookSerializationV1( + app=AppInstantiation(options={}), + cells=[CellDef(name="__", code="", options={})], + filename="notebook.py", + ) + + markdown = convert_from_ir_to_markdown( + notebook, filename="notebook.myst.md", flavor="mystmd" + ) + round_tripped = convert_from_md_to_marimo_ir(markdown) + + assert "pass" not in markdown + assert len(round_tripped.cells) == 1 + assert round_tripped.cells[0].code == "" + + +def test_markdown_code_cell_attributes_are_unescaped() -> None: + script_lines = ( + '```python {.marimo name="a"b & <c>"}', + "x = 1", + "```", + ) + + notebook_ir = convert_from_md_to_marimo_ir("\n".join(script_lines)) + + assert len(notebook_ir.cells) == 1 + assert notebook_ir.cells[0].name == 'a"b & ' + + markdown = convert_from_ir_to_markdown( + notebook_ir, filename="notebook.md", flavor="pymdown" + ) + + assert 'name="a"b & <c>"' in markdown + + def test_no_frontmatter() -> None: script = dedent( remove_empty_lines( diff --git a/tests/_convert/markdown/test_markdown_from_ir.py b/tests/_convert/markdown/test_markdown_from_ir.py index d14a75574c5..32be1a2d9c1 100644 --- a/tests/_convert/markdown/test_markdown_from_ir.py +++ b/tests/_convert/markdown/test_markdown_from_ir.py @@ -1,7 +1,12 @@ # Copyright 2024 Marimo. All rights reserved. from __future__ import annotations +import pytest + from marimo._ast.app import App, InternalApp +from marimo._convert.markdown.flavor import ( + markdown_output_filename, +) from marimo._convert.markdown.from_ir import ( _format_filename_title, _get_sql_options_from_cell, @@ -16,6 +21,7 @@ def test_format_filename_title(): assert _format_filename_title("/path/to/my_notebook.py") == "My Notebook" assert _format_filename_title("simple.py") == "Simple" assert _format_filename_title("my-cool_notebook.md") == "My Cool Notebook" + assert _format_filename_title("notebook.myst.md") == "Notebook" def test_get_sql_options_from_cell_basic(): @@ -305,13 +311,98 @@ def test_cell(): notebook, filename="notebook.qmd" ) assert "```{marimo .python" in markdown_qmd - assert "filters:" in markdown_qmd # qmd should have marimo filter + + markdown_mystmd = convert_from_ir_to_markdown( + notebook, filename="notebook.myst.md" + ) + assert "```{marimo} python" in markdown_mystmd # Test .md filename produces standard format markdown_md = convert_from_ir_to_markdown(notebook, filename="notebook.md") - assert "```{marimo .python" not in markdown_md # Should use either superfences or fallback format assert ( "```python {.marimo" in markdown_md or "```{.python.marimo" in markdown_md ) + + +def test_convert_from_ir_to_markdown_explicit_flavor(): + """Test that explicit flavors override filename inference.""" + app = App() + + @app.cell() + def test_cell(): + x = 1 + return (x,) + + internal_app = InternalApp(app) + notebook = internal_app.to_ir() + + markdown_qmd = convert_from_ir_to_markdown( + notebook, filename="notebook.md", flavor="qmd" + ) + assert "```{marimo .python" in markdown_qmd + + markdown_md = convert_from_ir_to_markdown( + notebook, filename="notebook.qmd", flavor="pymdown" + ) + assert ( + "```python {.marimo" in markdown_md + or "```{.python.marimo" in markdown_md + ) + + +@pytest.mark.parametrize( + ("filename", "flavor", "expected"), + [ + ("source.py", "pymdown", "source.md"), + ("source.py", "qmd", "source.qmd"), + ("source.py", "mystmd", "source.myst.md"), + ("source.qmd", "pymdown", "source.md"), + ("source.myst.md", "pymdown", "source.md"), + ("source.myst.md", "mystmd", "source.myst.md"), + (None, "qmd", "notebook.qmd"), + ], +) +def test_markdown_output_filename(filename, flavor, expected): + assert markdown_output_filename(filename, flavor) == expected + + +@pytest.mark.parametrize( + ("filename", "flavor", "expected_head"), + [ + ("notebook.md", "pymdown", "```python"), + ("notebook.qmd", "qmd", "```{marimo .python"), + ], +) +def test_convert_from_ir_to_markdown_escapes_code_cell_attributes( + filename: str, + flavor: str, + expected_head: str, +) -> None: + from marimo._schemas.serialization import ( + AppInstantiation, + CellDef, + NotebookSerializationV1, + ) + + notebook = NotebookSerializationV1( + app=AppInstantiation(options={}), + cells=[ + CellDef( + name='a"b & ', + code="x = 1", + options={}, + ) + ], + violations=[], + valid=True, + filename="notebook.py", + ) + + markdown = convert_from_ir_to_markdown( + notebook, filename=filename, flavor=flavor + ) + + assert expected_head in markdown + assert 'name="a"b & <c>"' in markdown diff --git a/tests/_server/api/endpoints/test_export.py b/tests/_server/api/endpoints/test_export.py index 2fb2310c194..73b132de9dd 100644 --- a/tests/_server/api/endpoints/test_export.py +++ b/tests/_server/api/endpoints/test_export.py @@ -213,6 +213,29 @@ def test_export_markdown(client: TestClient) -> None: ) +@with_session(SESSION_ID) +def test_export_markdown_download_uses_qmd_filename( + client: TestClient, *, temp_marimo_file: str +) -> None: + qmd_path = Path(temp_marimo_file).with_suffix(".qmd") + qmd_path.write_text("```{marimo .python}\nx = 1\n```", encoding="utf-8") + session = get_session_manager(client).get_session(SESSION_ID) + assert session + session.app_file_manager.filename = str(qmd_path) + + response = client.post( + "/api/export/markdown", + headers=HEADERS, + json={ + "download": True, + }, + ) + + assert response.status_code == 200 + assert "```{marimo .python" in response.text + assert qmd_path.name in response.headers["Content-Disposition"] + + @pytest.mark.skipif( not DependencyManager.nbformat.has(), reason="nbformat not installed" ) diff --git a/tests/_server/export/test_exporter.py b/tests/_server/export/test_exporter.py index 09da7230608..2335d8309b6 100644 --- a/tests/_server/export/test_exporter.py +++ b/tests/_server/export/test_exporter.py @@ -19,6 +19,7 @@ from marimo._messaging.msgspec_encoder import encode_json_str from marimo._messaging.notification import CellNotification from marimo._server.export import ( + export_as_md, export_as_wasm, run_app_then_export_as_pdf, run_app_until_completion, @@ -48,6 +49,29 @@ ) +@pytest.mark.parametrize( + ("filename", "source", "expected_fence"), + [ + ("demo.qmd", "```{marimo .python}\nx = 1\n```", "```{marimo .python"), + ( + "demo.myst.md", + "```{marimo} python\nx = 1\n```", + "```{marimo} python", + ), + ], +) +def test_export_as_md_uses_resolved_markdown_filename( + tmp_path: Path, filename: str, source: str, expected_fence: str +) -> None: + notebook = tmp_path / filename + notebook.write_text(source, encoding="utf-8") + + result = export_as_md(MarimoPath(notebook)) + + assert result.download_filename == filename + assert expected_fence in result.text + + def _print_messages(messages: list[CellNotification]) -> str: result: list[dict[str, Any]] = [] for message in messages: diff --git a/tests/_server/files/test_os_file_system.py b/tests/_server/files/test_os_file_system.py index 543fe908933..93457089ced 100644 --- a/tests/_server/files/test_os_file_system.py +++ b/tests/_server/files/test_os_file_system.py @@ -41,7 +41,7 @@ def test_create_file(test_dir: Path, fs: OSFileSystem) -> None: [ ("py", "__generated_with"), ("md", "marimo-version:"), - ("qmd", "marimo-team/marimo"), + ("qmd", "```{marimo .python}"), ], ) def test_create_notebook( diff --git a/tests/_server/test_file_manager.py b/tests/_server/test_file_manager.py index b836921cd4a..2ee14f82e3c 100644 --- a/tests/_server/test_file_manager.py +++ b/tests/_server/test_file_manager.py @@ -153,7 +153,6 @@ def test_rename_to_qmd(app_file_manager: AppFileManager) -> None: with open(initial_filename) as f: contents = f.read() assert "app = marimo.App()" in contents - assert "marimo-team/marimo" not in contents assert "marimo-version" not in contents app_file_manager.rename(str(initial_filename)[:-3] + ".qmd") next_filename = app_file_manager.filename @@ -162,11 +161,71 @@ def test_rename_to_qmd(app_file_manager: AppFileManager) -> None: with open(next_filename) as f: contents = f.read() assert "marimo-version" in contents - assert "filters:" in contents - assert "marimo-team/marimo" in contents + assert "```{marimo .python}" in contents assert "app = marimo.App()" not in contents +def test_save_mystmd_preserves_frontmatter_and_marimo_config( + tmp_path: Path, +) -> None: + temp_file = tmp_path / "notebook.myst.md" + temp_file.write_text( + "---\n" + "title: My Title\n" + "author: Marimo Team\n" + "---\n" + "\n" + "```{marimo-config}\n" + "---\n" + "header: |-\n" + " import os\n" + "pyproject: |-\n" + ' dependencies = ["polars"]\n' + "width: full\n" + "---\n" + "```\n" + "\n" + "```{marimo} python\n" + "x = 1\n" + "```", + encoding="utf-8", + ) + manager = AppFileManager(filename=str(temp_file)) + + assert manager.app.config.app_title == "My Title" + assert manager.app.config.width == "full" + assert manager.app.to_ir().header is not None + assert "import os" in manager.app.to_ir().header.value + assert 'dependencies = ["polars"]' in manager.app.to_ir().header.value + + cells = list(manager.app.cell_manager.cell_data()) + manager.save( + SaveNotebookRequest( + cell_ids=[cell.cell_id for cell in cells], + filename=str(temp_file), + codes=[cell.code for cell in cells], + names=[cell.name for cell in cells], + configs=[cell.config for cell in cells], + persist=True, + ) + ) + + contents = temp_file.read_text(encoding="utf-8") + assert "title: My Title" in contents + assert "author: Marimo Team" in contents + assert "```{marimo-config}" in contents + assert "import os" in contents + assert 'dependencies = ["polars"]' in contents + assert "width: full" in contents + + reloaded = AppFileManager(filename=str(temp_file)) + assert reloaded.app.config.app_title == "My Title" + assert reloaded.app.config.width == "full" + assert reloaded.app.to_ir().header is not None + assert "import os" in reloaded.app.to_ir().header.value + assert 'dependencies = ["polars"]' in reloaded.app.to_ir().header.value + + def test_save_app_config_valid(app_file_manager: AppFileManager) -> None: app_file_manager.filename = "app_config.py" try: